From 13c8436d98b7063052537cbd8385e03440b4ecf6 Mon Sep 17 00:00:00 2001 From: Matthew Northcott Date: Thu, 20 Apr 2023 14:59:00 +1200 Subject: [PATCH 1/3] [#41] Allow users to download the user/planng guide for a payment - backend changes to support physical and digital checkouts --- backend/right_tree/api/admin.py | 6 +- backend/right_tree/api/filters.py | 94 +++-------- .../api/migrations/0017_auto_20230420_1457.py | 50 ++++++ backend/right_tree/api/models.py | 23 +++ .../api/resource_generation_utils.py | 84 ++++------ backend/right_tree/api/serializers.py | 2 +- backend/right_tree/api/signals.py | 1 + backend/right_tree/api/views.py | 158 ++++++++++++------ backend/right_tree/settings.py | 5 +- docker-compose.production.yaml | 1 + docker-compose.yaml | 10 -- 11 files changed, 255 insertions(+), 179 deletions(-) create mode 100644 backend/right_tree/api/migrations/0017_auto_20230420_1457.py diff --git a/backend/right_tree/api/admin.py b/backend/right_tree/api/admin.py index 7c017de..e7b3018 100644 --- a/backend/right_tree/api/admin.py +++ b/backend/right_tree/api/admin.py @@ -36,12 +36,12 @@ class ActivationKeySetAdmin(admin.ModelAdmin): class ActivationKeyAdmin(admin.ModelAdmin): - list_display = ['key', 'creation_date', 'key_set', 'remaining_activations'] + list_display = ['key', 'creation_date', 'key_set', 'activations', 'remaining_activations'] list_filter = ['creation_date', 'key_set'] class QuestionnaireAdmin(admin.ModelAdmin): - list_display = ['address_display', 'location_display', 'soil_variant', 'ecological_district_display', 'habitat', 'zone', 'key_set_display'] + list_display = ['address_display', 'location_display', 'soil_variant', 'ecological_district_display', 'habitat', 'zone', 'key_set_display', 'creation_date'] list_filter = [ActivationKeySetFilter] actions = ['export'] @@ -134,3 +134,5 @@ admin.site.register(models.ActivationKey, ActivationKeyAdmin) admin.site.register(models.ActivationKeySet, ActivationKeySetAdmin) admin.site.register(models.Questionnaire, QuestionnaireAdmin) admin.site.register(models.Export, ExportAdmin) +admin.site.register(models.Customer) +admin.site.register(models.CustomerAddress) diff --git a/backend/right_tree/api/filters.py b/backend/right_tree/api/filters.py index ee5f988..96b52c0 100644 --- a/backend/right_tree/api/filters.py +++ b/backend/right_tree/api/filters.py @@ -1,64 +1,7 @@ -import json - -from django.http import Http404 -from django.db.models import Q - -from .models import Plant, EcologicalRegion, EcologicalDistrictLayer, ChristchurchRegion, SoilOrder, SoilVariant +from .models import Plant, EcologicalRegion, EcologicalDistrictLayer, ChristchurchRegion, SoilOrder, SoilVariant, ActivationKey, Questionnaire from .wms_utils import get_point_from_coordinates -def coordinate_filter(request, queryset, ignore_soil_order=False): - coordinates = request.query_params.get('coordinates') - - if coordinates is not None: - pnt = get_point_from_coordinates(coordinates) - filtered_regions = EcologicalRegion.objects.filter( - ecologicaldistrictlayer__geom__intersects=pnt).values_list('id', flat=True) - filtered_soil_orders = SoilOrder.objects.filter( - soillayer__geom__intersects=pnt).values_list('id', flat=True) - - # Filter by ecological regions and soil orders - if ignore_soil_order: - return queryset.filter(ecological_regions__in=filtered_regions).distinct() - else: - return queryset.filter( - Q(ecological_regions__in=filtered_regions) & - Q(soil_order__in=filtered_soil_orders)).distinct() - - return queryset - - -def soil_variant_filter(request, queryset): - soil_variant = request.query_params.get('soilVariant') - - if soil_variant in {"D", "W", "M"}: - soil_variant_ids = SoilVariant.objects.filter(Q(name__startswith=soil_variant) | Q( - name__startswith="M")).values_list('id', flat=True).distinct() - return queryset.filter(soil_variants__in=soil_variant_ids).distinct() - - return queryset - - -def zone_filter(zone_json, queryset): - return queryset.filter(zones__id__contains=zone_json['id']).distinct() - - -def soil_order_coordinate_filter(coordinates): - pnt = get_point_from_coordinates(coordinates) - try: - return SoilOrder.objects.filter(soillayer__geom__intersects=pnt) - except SoilOrder.DoesNotExist: - raise Http404(f"Soil Order cannot be found for point {pnt}") - - -def ecological_district_coordinate_filter(coordinates): - pnt = get_point_from_coordinates(coordinates) - try: - return EcologicalDistrictLayer.objects.filter(geom__intersects=pnt) - except EcologicalDistrictLayer.DoesNotExist: - raise Http404( - f"Ecological district layer cannot be found for point {pnt}") - def is_in_auckland(coordinates): pnt = get_point_from_coordinates(coordinates) eco_district = EcologicalDistrictLayer.objects.filter(geom__intersects=pnt).first() @@ -68,22 +11,31 @@ def is_in_auckland(coordinates): def is_in_christchurch(coordinates): pnt = get_point_from_coordinates(coordinates) in_chch = ChristchurchRegion.objects.filter(geom__intersects=pnt).first() - return in_chch is not None; + return in_chch is not None def get_filtered_plants(request): - filtered_plants = Plant.objects.all() + try: + ak = ActivationKey.objects.get(key=request.query_params['key']) + except (KeyError, ActivationKey.DoesNotExist): + raise ValueError("Invalid key") - zone = request.query_params.get('zone') - if zone != None: - zone_json = json.loads(zone) - filtered_plants = zone_filter(zone_json, filtered_plants) + if not (q := Questionnaire.objects.filter(key=ak).order_by("-creation_date").first()): + return Plant.objects.none() - if not zone_json['ignore_location_filter']: - filtered_plants = coordinate_filter( - request, filtered_plants, ignore_soil_order=zone_json['ignore_soil_order_filter']) - else: - filtered_plants = coordinate_filter(request, filtered_plants) + regions = EcologicalRegion.objects.filter(ecologicaldistrictlayer__geom__intersects=q.location).distinct() + soils = ( + SoilVariant.objects.filter(name="Mesic") | + SoilVariant.objects.filter(name__istartswith=q.soil_variant) + ).distinct() - filtered_plants = soil_variant_filter(request, filtered_plants) + qs = Plant.objects.filter( + zones__in=[q.zone], + ecological_regions__in=regions, + soil_variants__in=soils, + ) - return filtered_plants + if not q.zone.ignore_soil_order_filter: + orders = SoilOrder.objects.filter(soillayer__geom__intersects=q.location).distinct() + qs = qs.filter(soil_order__in=orders).distinct() + + return qs.distinct() diff --git a/backend/right_tree/api/migrations/0017_auto_20230420_1457.py b/backend/right_tree/api/migrations/0017_auto_20230420_1457.py new file mode 100644 index 0000000..13cb9d2 --- /dev/null +++ b/backend/right_tree/api/migrations/0017_auto_20230420_1457.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.17 on 2023-04-20 02:57 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0016_alter_activationkey_key_set'), + ] + + operations = [ + migrations.CreateModel( + name='CustomerAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('city', models.CharField(max_length=100)), + ('line1', models.CharField(max_length=255)), + ('line2', models.CharField(blank=True, max_length=255, null=True)), + ('postal_code', models.SmallIntegerField()), + ], + ), + migrations.AddField( + model_name='activationkey', + name='activations', + field=models.SmallIntegerField(default=0), + ), + migrations.AddField( + model_name='questionnaire', + name='creation_date', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('name', models.CharField(max_length=100)), + ('address', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='api.customeraddress')), + ], + ), + migrations.AddField( + model_name='activationkey', + name='customer', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='api.customer'), + ), + ] diff --git a/backend/right_tree/api/models.py b/backend/right_tree/api/models.py index 281a348..55a4257 100644 --- a/backend/right_tree/api/models.py +++ b/backend/right_tree/api/models.py @@ -202,6 +202,26 @@ class Address(models.Model): managed = False +class CustomerAddress(models.Model): + city = models.CharField(max_length=100) + line1 = models.CharField(max_length=255) + line2 = models.CharField(max_length=255, blank=True, null=True) + postal_code = models.SmallIntegerField() + + def __str__(self): + return f"{self.line1}, {self.line2}, {self.city} {self.postal_code}" \ + if self.line2 else f"{self.line1}, {self.city} {self.postal_code}" + + +class Customer(models.Model): + email = models.EmailField() + name = models.CharField(max_length=100) + address = models.ForeignKey(CustomerAddress, on_delete=models.PROTECT) + + def __str__(self): + return self.name + + class ActivationKeySet(models.Model): creation_date = models.DateTimeField(auto_now_add=True) name = models.CharField(max_length=255, unique=True) @@ -223,8 +243,10 @@ class ActivationKey(models.Model): key = models.CharField(max_length=20, unique=True, default=key_default) key_set = models.ForeignKey(ActivationKeySet, on_delete=models.PROTECT, null=True) + activations = models.SmallIntegerField(default=0) remaining_activations = models.SmallIntegerField(default=1) creation_date = models.DateTimeField(auto_now_add=True) + customer = models.ForeignKey(Customer, on_delete=models.PROTECT, null=True) def __str__(self): return self.key @@ -235,6 +257,7 @@ class Questionnaire(models.Model): soil_variant = models.ForeignKey(SoilVariant, on_delete=models.CASCADE) zone = models.ForeignKey(Zone, on_delete=models.CASCADE) key = models.ForeignKey(ActivationKey, on_delete=models.PROTECT, null=True) + creation_date = models.DateTimeField(auto_now_add=True) @property def habitat(self): diff --git a/backend/right_tree/api/resource_generation_utils.py b/backend/right_tree/api/resource_generation_utils.py index facc39c..5f3b9e1 100644 --- a/backend/right_tree/api/resource_generation_utils.py +++ b/backend/right_tree/api/resource_generation_utils.py @@ -2,8 +2,9 @@ import csv from io import StringIO from os.path import splitext -from .filters import * -from .wms_utils import get_address_from_coordinates, get_point_from_coordinates +from .filters import is_in_christchurch, is_in_auckland +from .models import EcologicalDistrictLayer, SoilOrder, Questionnaire, ActivationKey +from .wms_utils import get_address_from_coordinates import pdfkit import pandas as pd @@ -22,75 +23,58 @@ HEADER_FIELDS = ['Names', 'Growth Form / Max Height (m) / Spacing (m) / Forest P 'Tolerances (Water / Drought / Frost / Salinity)', 'Ecosystem Services', 'Carbon Sequestration Rate', 'Planting Stage'] -def get_location_filters(params): +def get_location_filters(questionnaire): """ Retrives the selected location data from the request. """ filter_rows = [['LOCATION FILTERS:', ' ']] - coordinates = params.get('coordinates') - if coordinates is not None: - eco_district_layer = ecological_district_coordinate_filter( - coordinates).first() - point = get_point_from_coordinates(coordinates) - address = get_address_from_coordinates(coordinates) + eco_district_layer = EcologicalDistrictLayer.objects.filter(geom__intersects=questionnaire.location).first() + address = get_address_from_coordinates(questionnaire.location) - filter_rows.append(['Point coordinates:', point]) - filter_rows.append(['Ecological region:', eco_district_layer.ecologic_1 or '' ]) - filter_rows.append(['Ecological district:', eco_district_layer.ecologic_2 or ' ']) - filter_rows.append(['Property address:', address['full_address'] if address is not None else ' ']) - else: - filter_rows.append(["None specified", " "]) + filter_rows.append(['Point coordinates:', questionnaire.location]) + filter_rows.append(['Ecological region:', eco_district_layer.ecologic_1 or '' ]) + filter_rows.append(['Ecological district:', eco_district_layer.ecologic_2 or ' ']) + filter_rows.append(['Property address:', address['full_address'] if address is not None else ' ']) return filter_rows -def get_soil_filters(params): +def get_soil_filters(questionnaire): """ Retrives the selected soil type data from the request params. """ filter_rows = [['SOIL FILTERS:', ' ']] - soil_variant = params.get('soilVariant') - coordinates = params.get('coordinates') - if soil_variant is not None and coordinates is not None: - soil_order_obj = soil_order_coordinate_filter(coordinates).first() + soil_order = SoilOrder.objects.filter(soillayer__geom__intersects=questionnaire.location).first() - filter_rows.append(['Soil Order:', f"{soil_order_obj.name or ' '} ({soil_order_obj.code or ' '})"]) - filter_rows.append(['Soil Variant:', soil_variant]) - else: - filter_rows.append(["None specified", " "]) + filter_rows.append(['Soil Order:', f"{soil_order.name or ' '} ({soil_order.code or ' '})"]) + filter_rows.append(['Soil Variant:', questionnaire.soil_variant]) return filter_rows -def get_site_filters(params): +def get_site_filters(questionnaire): """ Retrives the selected site data from the request params """ filter_rows = [['SITE FILTERS:', ' ']] - habitat = params.get('habitat') - zone = params.get('zone') - if zone is not None and habitat is not None: - habitat_json = json.loads(habitat) - zone_json = json.loads(zone) - filter_rows.append(['Habitat:', habitat_json.get("name", " ")]) - filter_rows.append(['Zone Name:', zone_json.get("name", " ")]) - filter_rows.append(['Zone Variant:', zone_json.get("variant", " ")]) - filter_rows.append(['Zone Refined Variant:', zone_json.get("refined_variant", " ")]) - else: - filter_rows.append(["None specified", " "]) + zone = questionnaire.zone + habitat = zone.habitat + + filter_rows.append(['Habitat:', habitat.name]) + filter_rows.append(['Zone Name:', zone.name]) + filter_rows.append(['Zone Variant:', zone.variant]) + filter_rows.append(['Zone Refined Variant:', zone.refined_variant]) return filter_rows -def get_additional_region_info(params): +def get_additional_region_info(questionnaire): """ If the location coordinates fall within the CHCH or Auckland regions then return a description of where to find more information. """ - coordinates = params.get('coordinates') - if coordinates is not None: - if is_in_christchurch(coordinates): - return [["Your location falls within the ecosystem type covered by the Christchurch Council ecosystem maps - further information can be obtained from Ōtautahi/Christchurch ecosystems map link to: https://ccc.govt.nz/environment/land/ecosystem-map", " "], [' ', ' ']] - elif is_in_auckland(coordinates): - return [["Your location falls within the ecosystem type covered by the Auckland Council Tiaki Tāmaki Makaurau Conservation map - further information can be obtained from tiaki Tāmaki Makaurau conservation Auckland - link to https://www.tiakitamakimakaurau.nz/conservation-map/", " "], [' ', ' ']] + if is_in_christchurch(questionnaire.location): + return [["Your location falls within the ecosystem type covered by the Christchurch Council ecosystem maps - further information can be obtained from Ōtautahi/Christchurch ecosystems map link to: https://ccc.govt.nz/environment/land/ecosystem-map", " "], [' ', ' ']] + elif is_in_auckland(questionnaire.location): + return [["Your location falls within the ecosystem type covered by the Auckland Council Tiaki Tāmaki Makaurau Conservation map - further information can be obtained from tiaki Tāmaki Makaurau conservation Auckland - link to https://www.tiakitamakimakaurau.nz/conservation-map/", " "], [' ', ' ']] return [] @@ -99,19 +83,25 @@ def get_filter_values(params): """ filter_rows = [] + try: + ak = ActivationKey.objects.get(key=params.get("key", "")) + q = Questionnaire.objects.get(key=ak) + except (ActivationKey.DoesNotExist, Questionnaire.DoesNotExist): + return filter_rows + # Add all the location filters - filter_rows += get_location_filters(params) + filter_rows += get_location_filters(q) filter_rows.append([' ', ' ']) # Add the soil filters - filter_rows += get_soil_filters(params) + filter_rows += get_soil_filters(q) filter_rows.append([' ', ' ']) # Add the project site filters - filter_rows += get_site_filters(params) + filter_rows += get_site_filters(q) filter_rows.append([' ', ' ']) - filter_rows += get_additional_region_info(params) + filter_rows += get_additional_region_info(q) return filter_rows diff --git a/backend/right_tree/api/serializers.py b/backend/right_tree/api/serializers.py index fd49f4a..f8e8802 100644 --- a/backend/right_tree/api/serializers.py +++ b/backend/right_tree/api/serializers.py @@ -104,7 +104,7 @@ class QuestionnaireSerializer(serializers.ModelSerializer): class Meta: model = Questionnaire - fields = '__all__' + exclude = ['id'] def validate_soil_variant(self, value): try: diff --git a/backend/right_tree/api/signals.py b/backend/right_tree/api/signals.py index 3ed6abf..c96d53d 100644 --- a/backend/right_tree/api/signals.py +++ b/backend/right_tree/api/signals.py @@ -32,4 +32,5 @@ def activate_key(sender, instance, created, *args, **kwargs): """Consume one activation on the key associated with the created Questionnaire""" if created and (key := instance.key): key.remaining_activations -= 1 + key.activations += 1 key.save() diff --git a/backend/right_tree/api/views.py b/backend/right_tree/api/views.py index 41baaca..8047d12 100644 --- a/backend/right_tree/api/views.py +++ b/backend/right_tree/api/views.py @@ -4,20 +4,20 @@ import stripe from datetime import timedelta from django.conf import settings -from django.http import HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, HttpResponseBadRequest, FileResponse +from django.contrib.gis.geos import Point +from django.http import JsonResponse, HttpResponseNotAllowed, HttpResponseNotFound, HttpResponseBadRequest, FileResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone from django.utils.text import slugify from rest_framework import viewsets, permissions -from rest_framework.decorators import action from rest_framework.response import Response -from .models import Habitat, HabitatImage, Plant, EcologicalDistrictLayer, SoilOrder, Zone, Questionnaire, ActivationKey, ActivationKeySet +from .models import Habitat, HabitatImage, Plant, EcologicalDistrictLayer, SoilOrder, Zone, Questionnaire, ActivationKey, ActivationKeySet, Customer, CustomerAddress from .serializers import HabitatImageSerializer, HabitatSerializer, PlantSerializer, SoilOrderSerializer, EcologicalDistrictLayerSerializer, AddressSerializer, ZoneSerializer, QuestionnaireSerializer from .filters import * from .wms_utils import get_address_from_coordinates, search_address -from .resource_generation_utils import generate_csv, get_filter_values, serialize_plants_queryset, create_planting_guide_pdf, PLANTING_GUIDE_PDF_FILENAME, CSV_FILENAME, storage +from .resource_generation_utils import generate_csv, get_filter_values, serialize_plants_queryset, create_planting_guide_pdf, PLANTING_GUIDE_PDF_FILENAME, storage from .redis import redis_client @@ -42,11 +42,14 @@ class SoilOrderViewSet(viewsets.ModelViewSet): def get_queryset(self): """ Filtering soil order query set by coordinate parameters in the URL. """ - coordinates = self.request.query_params.get('coordinates') - if coordinates is not None: - return soil_order_coordinate_filter(coordinates) + try: + lat = float(self.request.query_params["lat"]) + lng = float(self.request.query_params["lng"]) + except (KeyError, ValueError): + return SoilOrder.objects.all() + + return SoilOrder.objects.filter(soillayer__geom__intersects=Point(lng, lat, srid=4326)) - return SoilOrder.objects.all() class EcologicalDistrictViewSet(viewsets.ModelViewSet): @@ -57,11 +60,13 @@ class EcologicalDistrictViewSet(viewsets.ModelViewSet): def get_queryset(self): """ Filtering ecological district/region query set by coordinate parameters in the URL. """ - coordinates = self.request.query_params.get('coordinates') - if coordinates is not None: - return ecological_district_coordinate_filter(coordinates) + try: + lat = float(self.request.query_params["lat"]) + lng = float(self.request.query_params["lng"]) + except (KeyError, ValueError): + return EcologicalDistrictLayer.objects.all() - return EcologicalDistrictLayer.objects.all() + return EcologicalDistrictLayer.objects.filter(geom__intersects=Point(lng, lat, srid=4326)) class LINZPropertyViewSet(viewsets.ViewSet): @@ -69,32 +74,48 @@ class LINZPropertyViewSet(viewsets.ViewSet): """ def list(self, request): - coordinates = self.request.query_params.get('coordinates') + try: + lat = float(self.request.query_params["lat"]) + lng = float(self.request.query_params["lng"]) + except (KeyError, ValueError): + lat = lng = None + address = self.request.query_params.get('search') if address is not None: results = search_address(address) return Response(results) - elif coordinates is not None: - address_data = get_address_from_coordinates(coordinates) + elif lat and lng: + address_data = get_address_from_coordinates(Point(lng, lat, srid=4326)) serializer = AddressSerializer(address_data) return Response(serializer.data) - else: - return HttpResponseBadRequest("No parameters given.") + + return HttpResponseBadRequest("Invalid parameters.") + class AuckCHCHRegionInformation(viewsets.ViewSet): """ Filtered viewset defining if coordinate falls inside auckland and chch regions. """ def list(self, request): - coordinates = self.request.query_params.get('coordinates') - if coordinates is not None: - in_chch = is_in_christchurch(coordinates) - in_auckland = is_in_auckland(coordinates) - region_details = {"in_chch": in_chch, "in_auckland": in_auckland} - return Response(region_details) - else: - return HttpResponseBadRequest("No coordinate given.") + try: + lat = float(self.request.query_params["lat"]) + lng = float(self.request.query_params["lng"]) + except (KeyError, ValueError): + return HttpResponseBadRequest("Missing or invalid coordinates.") + + p = Point(lng, lat, srid=4326) + in_chch = False + in_auckland = False + + # can avoid computing intersections for Auckland if we use a conditional here + if is_in_christchurch(p): + in_chch = True + elif is_in_auckland(p): + in_auckland = True + + return Response({"in_chch": in_chch, "in_auckland": in_auckland}) + class HabitatViewSet(viewsets.ModelViewSet): """ Viewset for all habitats. @@ -188,35 +209,62 @@ def validate_key(request): return HttpResponseBadRequest("'key' not specified") try: - if ActivationKey.objects.get(key=key).remaining_activations > 0: - return HttpResponse() + ak = ActivationKey.objects.get(key=key) except ActivationKey.DoesNotExist: - pass + return HttpResponseNotFound() - return HttpResponseNotFound() + if ak.remaining_activations > 0: + # valid key, permit entry + return JsonResponse({"type": ak.key_set.name}) + elif ak.activations == 1: + # key has been activated, but can return the existing data from that activation + return JsonResponse(QuestionnaireSerializer(ak.questionnaire_set.first()).data) + + # key has multiple activations, but all are expended + # could return most recent questionnaire but user who uses many-use keys probably doesn't care + return HttpResponseBadRequest() def activate_key(request): """Adds a single activation to a given key if a Stripe payment has succeeded""" - redirect_url = "/apply" - try: - key = request.GET['key'] - stripe_session_id = redis_client.getdel(key).decode() - stripe_session = stripe.checkout.Session.retrieve(stripe_session_id) - status = stripe_session.payment_status - except (KeyError, AttributeError): - return redirect(redirect_url) + stripe.api_key = settings.STRIPE_API_KEY - match status: - case "paid": - ActivationKey.objects.create( - key=key, - key_set=ActivationKeySet.objects.get_or_create(name="Stripe", size=0)[0], - ) - redirect_url += "?key=" + key - case "open": - stripe.checkout.Session.expire(stripe_session_id) + redirect_url = "/" + + key = request.GET['key'] + stripe_session_id = redis_client.get(key).decode() + stripe_session = stripe.checkout.Session.retrieve(stripe_session_id) + is_physical = stripe_session.metadata.get("physical") == "true" + kwargs = {} + + if is_physical: + address, _ = CustomerAddress.objects.get_or_create( + city=stripe_session.customer_details.address['city'], + line1=stripe_session.customer_details.address['line1'], + line2=stripe_session.customer_details.address['line2'], + postal_code=int(stripe_session.customer_details.address['postal_code']), + ) + customer, _ = Customer.objects.get_or_create( + email=stripe_session.customer_details.email, + name=stripe_session.customer_details.name, + address=address, + ) + key_set, _ = ActivationKeySet.objects.get_or_create( + name=settings.STRIPE_PHYSICAL_KEY_SET, + size=0, + ) + kwargs["customer"] = customer + else: + key_set, _ = ActivationKeySet.objects.get_or_create( + name=settings.STRIPE_DIGITAL_KEY_SET, + size=0, + ) + + if stripe_session.payment_status == "paid": + ActivationKey.objects.create(key=key, key_set=key_set, **kwargs) + redis_client.delete(key) + redirect_url += "?key=" + key return redirect(redirect_url) @@ -225,13 +273,28 @@ def purchase_key(request): """Generate a prospective key and redirect to the Stripe payment portal""" stripe.api_key = settings.STRIPE_API_KEY + price_id = settings.STRIPE_DIGITAL_PRICE_ID + extra_kwargs = {} key = ActivationKey.key_default() redirect_url = request.build_absolute_uri(reverse(activate_key)) + f"?key={key}" + + # requesting checkout for physical copy + if request.GET.get("physical", "").lower() in {"t", "true", "y", "yes", "1"}: + price_id = settings.STRIPE_PHYSICAL_PRICE_ID + extra_kwargs = { + 'shipping_address_collection': { + 'allowed_countries': ['NZ'], + }, + 'metadata': { + 'physical': 'true', + }, + } + stripe_session = stripe.checkout.Session.create( line_items=[ { - "price": settings.STRIPE_PRICE_ID, + "price": price_id, "quantity": 1, }, ], @@ -247,6 +310,7 @@ def purchase_key(request): mode='payment', success_url=redirect_url, cancel_url=redirect_url, + **extra_kwargs, ) redis_client.setex(key, timedelta(hours=8), stripe_session.id) diff --git a/backend/right_tree/settings.py b/backend/right_tree/settings.py index a5ef862..7631c31 100644 --- a/backend/right_tree/settings.py +++ b/backend/right_tree/settings.py @@ -164,4 +164,7 @@ CELERY_BROKER_URL = REDIS_CELERY_URL # Stripe payment processing STRIPE_API_KEY = os.environ['STRIPE_API_KEY'] -STRIPE_PRICE_ID = os.environ['STRIPE_PRICE_ID'] +STRIPE_DIGITAL_PRICE_ID = os.environ['STRIPE_DIGITAL_PRICE_ID'] +STRIPE_PHYSICAL_PRICE_ID = os.environ['STRIPE_PHYSICAL_PRICE_ID'] +STRIPE_DIGITAL_KEY_SET = "Stripe - digital" +STRIPE_PHYSICAL_KEY_SET = "Stripe - physical" diff --git a/docker-compose.production.yaml b/docker-compose.production.yaml index 7d8e24b..3cdcacf 100644 --- a/docker-compose.production.yaml +++ b/docker-compose.production.yaml @@ -26,6 +26,7 @@ services: - manage.py - collectstatic - --noinput + restart: on-failure backend: <<: *django diff --git a/docker-compose.yaml b/docker-compose.yaml index 5aebc23..4a8551b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,14 +13,6 @@ x-django: &django restart: unless-stopped services: - collectstatic: - <<: *django - container_name: collectstatic - command: - - python - - manage.py - - collectstatic - - --noinput backend: <<: *django @@ -32,8 +24,6 @@ services: condition: service_healthy celery: condition: service_healthy - collectstatic: - condition: service_completed_successfully expose: - "8000" command: -- 2.45.2 From a7d7581c1ba7b37b19fde801b7c1a28e10d10aea Mon Sep 17 00:00:00 2001 From: Matthew Northcott Date: Thu, 20 Apr 2023 15:12:22 +1200 Subject: [PATCH 2/3] Backend style fixes --- backend/right_tree/api/apps.py | 1 + backend/right_tree/api/filters.py | 13 +++++++++++- backend/right_tree/api/models.py | 1 - .../api/resource_generation_utils.py | 3 ++- backend/right_tree/api/serializers.py | 20 ++++++++++++++++++- backend/right_tree/api/views.py | 5 ++--- backend/right_tree/api/wms_utils.py | 2 +- backend/right_tree/settings.py | 2 +- 8 files changed, 38 insertions(+), 9 deletions(-) diff --git a/backend/right_tree/api/apps.py b/backend/right_tree/api/apps.py index 10ab459..7101028 100644 --- a/backend/right_tree/api/apps.py +++ b/backend/right_tree/api/apps.py @@ -5,4 +5,5 @@ class ApiConfig(AppConfig): name = 'right_tree.api' def ready(self): + # flake8: noqa import right_tree.api.signals diff --git a/backend/right_tree/api/filters.py b/backend/right_tree/api/filters.py index 96b52c0..730ab57 100644 --- a/backend/right_tree/api/filters.py +++ b/backend/right_tree/api/filters.py @@ -1,4 +1,13 @@ -from .models import Plant, EcologicalRegion, EcologicalDistrictLayer, ChristchurchRegion, SoilOrder, SoilVariant, ActivationKey, Questionnaire +from .models import ( + Plant, + EcologicalRegion, + EcologicalDistrictLayer, + ChristchurchRegion, + SoilOrder, + SoilVariant, + ActivationKey, + Questionnaire +) from .wms_utils import get_point_from_coordinates @@ -8,11 +17,13 @@ def is_in_auckland(coordinates): print(eco_district.ecologic_2) return eco_district is not None and eco_district.ecologic_2.name == 'Auckland' + def is_in_christchurch(coordinates): pnt = get_point_from_coordinates(coordinates) in_chch = ChristchurchRegion.objects.filter(geom__intersects=pnt).first() return in_chch is not None + def get_filtered_plants(request): try: ak = ActivationKey.objects.get(key=request.query_params['key']) diff --git a/backend/right_tree/api/models.py b/backend/right_tree/api/models.py index 55a4257..69b542e 100644 --- a/backend/right_tree/api/models.py +++ b/backend/right_tree/api/models.py @@ -11,7 +11,6 @@ from django.contrib.postgres.indexes import OpClass from django.utils.text import slugify - class SoilOrder(models.Model): code = models.CharField(unique=True, max_length=1) name = models.CharField(unique=True, max_length=50) diff --git a/backend/right_tree/api/resource_generation_utils.py b/backend/right_tree/api/resource_generation_utils.py index 5f3b9e1..d667eb6 100644 --- a/backend/right_tree/api/resource_generation_utils.py +++ b/backend/right_tree/api/resource_generation_utils.py @@ -32,7 +32,7 @@ def get_location_filters(questionnaire): address = get_address_from_coordinates(questionnaire.location) filter_rows.append(['Point coordinates:', questionnaire.location]) - filter_rows.append(['Ecological region:', eco_district_layer.ecologic_1 or '' ]) + filter_rows.append(['Ecological region:', eco_district_layer.ecologic_1 or '']) filter_rows.append(['Ecological district:', eco_district_layer.ecologic_2 or ' ']) filter_rows.append(['Property address:', address['full_address'] if address is not None else ' ']) @@ -78,6 +78,7 @@ def get_additional_region_info(questionnaire): return [] + def get_filter_values(params): """ Retrives all selected values/filters from the request parameters """ diff --git a/backend/right_tree/api/serializers.py b/backend/right_tree/api/serializers.py index f8e8802..592b8e0 100644 --- a/backend/right_tree/api/serializers.py +++ b/backend/right_tree/api/serializers.py @@ -1,5 +1,18 @@ from rest_framework import serializers, exceptions -from right_tree.api.models import * + +from .models import ( + ToleranceLevel, + EcologicalRegion, + EcologicalDistrictLayer, + SoilOrder, + SoilVariant, + HabitatImage, + Habitat, + Zone, + Plant, + Questionnaire, + ActivationKey, +) class ToleranceLevelSerializer(serializers.HyperlinkedModelSerializer): @@ -39,8 +52,10 @@ class SoilVariantSerializer(serializers.HyperlinkedModelSerializer): model = SoilVariant fields = ['name'] + class HabitatImageSerializer(serializers.HyperlinkedModelSerializer): id = serializers.ReadOnlyField() + class Meta: model = HabitatImage fields = ['id', 'name', 'image_filename'] @@ -54,6 +69,7 @@ class HabitatSerializer(serializers.HyperlinkedModelSerializer): model = Habitat fields = ['id', 'name', 'images'] + class ZoneSerializer(serializers.HyperlinkedModelSerializer): id = serializers.ReadOnlyField() habitat = HabitatSerializer() @@ -63,6 +79,8 @@ class ZoneSerializer(serializers.HyperlinkedModelSerializer): model = Zone fields = ['id', 'name', 'variant', 'refined_variant', 'habitat', 'related_svg_segment', 'redirect_habitat', 'ignore_soil_order_filter', 'ignore_location_filter', 'tooltip_display_text'] + + class LocationDetailsSerializer(serializers.Serializer): ecologic_1 = serializers.CharField(max_length=50) ecologic_2 = serializers.CharField(max_length=50) diff --git a/backend/right_tree/api/views.py b/backend/right_tree/api/views.py index 8047d12..d179a4c 100644 --- a/backend/right_tree/api/views.py +++ b/backend/right_tree/api/views.py @@ -15,7 +15,7 @@ from rest_framework.response import Response from .models import Habitat, HabitatImage, Plant, EcologicalDistrictLayer, SoilOrder, Zone, Questionnaire, ActivationKey, ActivationKeySet, Customer, CustomerAddress from .serializers import HabitatImageSerializer, HabitatSerializer, PlantSerializer, SoilOrderSerializer, EcologicalDistrictLayerSerializer, AddressSerializer, ZoneSerializer, QuestionnaireSerializer -from .filters import * +from .filters import get_filtered_plants, is_in_auckland, is_in_christchurch from .wms_utils import get_address_from_coordinates, search_address from .resource_generation_utils import generate_csv, get_filter_values, serialize_plants_queryset, create_planting_guide_pdf, PLANTING_GUIDE_PDF_FILENAME, storage from .redis import redis_client @@ -51,7 +51,6 @@ class SoilOrderViewSet(viewsets.ModelViewSet): return SoilOrder.objects.filter(soillayer__geom__intersects=Point(lng, lat, srid=4326)) - class EcologicalDistrictViewSet(viewsets.ModelViewSet): """ Filtered viewset for ecological district/region details. """ @@ -273,7 +272,7 @@ def purchase_key(request): """Generate a prospective key and redirect to the Stripe payment portal""" stripe.api_key = settings.STRIPE_API_KEY - price_id = settings.STRIPE_DIGITAL_PRICE_ID + price_id = settings.STRIPE_DIGITAL_PRICE_ID extra_kwargs = {} key = ActivationKey.key_default() diff --git a/backend/right_tree/api/wms_utils.py b/backend/right_tree/api/wms_utils.py index bcadac0..e978266 100644 --- a/backend/right_tree/api/wms_utils.py +++ b/backend/right_tree/api/wms_utils.py @@ -53,7 +53,7 @@ def wfs_getfeature(endpoint, **kwargs): try: return response.json() - except json.JSONDecodeError as e: + except json.JSONDecodeError: raise WFSError( f"Failed to make WFS request to {url}: {response.content}") diff --git a/backend/right_tree/settings.py b/backend/right_tree/settings.py index 7631c31..af5cfe3 100644 --- a/backend/right_tree/settings.py +++ b/backend/right_tree/settings.py @@ -28,7 +28,7 @@ SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", 'django-insecure-5t05qc2&14xuot4lgs# DEBUG = os.getenv('DJANGO_DEBUG_MODE', '') != 'False' # os.getenv("ALLOWED_HOSTS", "").split(","), -ALLOWED_HOSTS = [BASE_URL, "localhost"] +ALLOWED_HOSTS = [BASE_URL, "localhost"] # Application definition -- 2.45.2 From d39e7da6f1bfd7ecc3fad6a26b821ffa8473cab9 Mon Sep 17 00:00:00 2001 From: Matthew Northcott Date: Thu, 20 Apr 2023 15:41:14 +1200 Subject: [PATCH 3/3] [#41] Allow users to download the user/plang guide for a payment - frontend changes to support physical and digital checkouts --- frontend/src/assets/data/staticText.json | 10 +- .../providers/ActivationProvider.jsx | 68 +++++++++++++ .../components/providers/ErrorProvider.jsx | 24 +++++ .../components/providers/StepperProvider.jsx | 2 + .../steps/activation/ActivationStep.jsx | 33 +++---- .../steps/activation/PurchaseButton.jsx | 44 +++++++++ .../components/steps/address/AddressStep.jsx | 49 ---------- .../steps/complete/CompleteStep.jsx | 24 ----- .../{address => location}/AddressSearch.jsx | 7 +- .../steps/location/LocationStep.jsx | 42 ++++++-- .../components/steps/results/PlantList.jsx | 54 +++++----- .../components/steps/results/ResultsStep.jsx | 98 +++++++++---------- .../steps/summary/SummaryContent.jsx | 72 ++++++++------ frontend/src/index.js | 21 ++-- frontend/src/pages/ApplyPage.jsx | 67 ------------- frontend/src/pages/MainPage.jsx | 56 ++++++++--- frontend/src/repository/LocationRepository.js | 25 ++--- frontend/src/repository/PlantRepository.js | 12 +-- 18 files changed, 381 insertions(+), 327 deletions(-) create mode 100644 frontend/src/components/providers/ActivationProvider.jsx create mode 100644 frontend/src/components/providers/ErrorProvider.jsx create mode 100644 frontend/src/components/steps/activation/PurchaseButton.jsx delete mode 100644 frontend/src/components/steps/address/AddressStep.jsx delete mode 100644 frontend/src/components/steps/complete/CompleteStep.jsx rename frontend/src/components/steps/{address => location}/AddressSearch.jsx (95%) delete mode 100644 frontend/src/pages/ApplyPage.jsx diff --git a/frontend/src/assets/data/staticText.json b/frontend/src/assets/data/staticText.json index 0ea6551..cd2c0ec 100644 --- a/frontend/src/assets/data/staticText.json +++ b/frontend/src/assets/data/staticText.json @@ -6,11 +6,7 @@ }, "location": { "title": "Right Plant Right Place Right Time\nPlant Selector Tool for New Zealand.", - "description": "Your native plant selection starts here! Use the map to select a planting site location within New Zealand. On the following pages you will provide more details on your project until the system has enough information to create your plant species list and planting plan. To start, click on the map and pan and zoom to the site location. Once the location is selected, click on the “next step” button to complete the process. Repeat this process for sites at different locations." - }, - "address": { - "title": "Address", - "description": "Thank you for purchasing an activation key. Please start entering your address and select an option from the suggestions provided. On the following pages, you will provide more details on your project until the system has enough information to create your plant species list and planting plan. Once the location is selected, click on the “next step” button to complete the process." + "description": "Your native plant selection starts here! Use the map or enter an address to select a planting site location within New Zealand. On the following pages you will provide more details on your project until the system has enough information to create your plant species list and planting plan. To start, click on the map and pan and zoom to the site location. Once the location is selected, click on the “next step” button to complete the process. Repeat this process for sites at different locations." }, "soil": { "title": "Soil Variant Selection", @@ -39,10 +35,6 @@ "results": { "title": "Plant List Results", "forestDiagramDescription": "Forest Position Information Diagram" - }, - "complete": { - "title": "Application Complete", - "description": "You have completed your application and submitted your results. You may now return to the homepage or fill out another application for a different habitat or zone." } } } diff --git a/frontend/src/components/providers/ActivationProvider.jsx b/frontend/src/components/providers/ActivationProvider.jsx new file mode 100644 index 0000000..4020c7c --- /dev/null +++ b/frontend/src/components/providers/ActivationProvider.jsx @@ -0,0 +1,68 @@ +import { createContext, useState, useContext } from 'react'; +import Repository from '../../repository/Repository'; +import { useError } from './ErrorProvider'; + +const ActivationContext = createContext(null); + +const ActivationProvider = ({children}) => { + const [key, setKey] = useState(""); + const [isPhysicalKey, setIsPhysicalKey] = useState(false); + const { setError, resetError } = useError(); + + const validateKey = (value) => new Promise(resolve => { + + const data = {key: value}; + + setKey(value); + + Repository.post("/key/validate/", data).then(resp => { + if (resp.data) { + setIsPhysicalKey(resp.data?.type === "Stripe - physical"); + resetError(); + try { + const coordinates = resp.data.location.match(/(-?\d+\.\d+)\s(-?\d+\.\d+)/); + resolve({ + coordinates: { + lng: parseFloat(coordinates[1]), + lat: parseFloat(coordinates[2]), + }, + soilVariant: resp.data.soil_variant[0], + zone: {id: resp.data.zone}, + ...data, + }); + } catch { + resolve({...data, ...resp.data}); + } + } else { + resolve(null); + } + }).catch(e => { + switch (e.response.status) { + case 400: + case 404: + setError("Invalid or expired activation key. Please try again."); + break; + default: + setError("Something went wrong. Please try again."); + break; + } + resolve(null); + }); + }); + + const value = { + key, + isDigitalKey: !isPhysicalKey, + isPhysicalKey, + validateKey, + }; + + return {children}; +}; + +const useActivator = () => useContext(ActivationContext); + +export { + ActivationProvider, + useActivator, +}; diff --git a/frontend/src/components/providers/ErrorProvider.jsx b/frontend/src/components/providers/ErrorProvider.jsx new file mode 100644 index 0000000..9fbf123 --- /dev/null +++ b/frontend/src/components/providers/ErrorProvider.jsx @@ -0,0 +1,24 @@ +import { createContext, useState, useContext } from 'react'; + +const ErrorContext = createContext(null); + +const ErrorProvider = ({children}) => { + const [error, setError] = useState(""); + + const resetError = () => setError(""); + + const value = { + error, + setError, + resetError, + }; + + return {children}; +}; + +const useError = () => useContext(ErrorContext); + +export { + ErrorProvider, + useError, +}; diff --git a/frontend/src/components/providers/StepperProvider.jsx b/frontend/src/components/providers/StepperProvider.jsx index c22608d..b64251a 100644 --- a/frontend/src/components/providers/StepperProvider.jsx +++ b/frontend/src/components/providers/StepperProvider.jsx @@ -15,12 +15,14 @@ const StepperWizard = ({children}) => { const isStep = n => (0 <= n && n < children.length); const setStepNext = () => setStep(n => isStep(n + 1) ? n + 1 : n); const setStepBack = () => setStep(n => isStep(n - 1) ? n - 1 : n); + const setStepLast = () => setStep(children.length - 1); const value = { step, setStep, setStepNext, setStepBack, + setStepLast, isStep, }; diff --git a/frontend/src/components/steps/activation/ActivationStep.jsx b/frontend/src/components/steps/activation/ActivationStep.jsx index 1bbee8d..ef0c60f 100644 --- a/frontend/src/components/steps/activation/ActivationStep.jsx +++ b/frontend/src/components/steps/activation/ActivationStep.jsx @@ -4,39 +4,36 @@ import StepInformation from '../StepInformation'; import staticText from '../../../assets/data/staticText.json' import keyBackgroundImage from '../../../assets/img/stepBackgrounds/step6.jpg'; import { StepperFooter, useStepper } from '../../providers/StepperProvider'; +import { useActivator } from '../../providers/ActivationProvider'; import { useFilter } from '../../providers/FilterProvider'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import TextField from '@mui/material/TextField'; -import Repository from '../../../repository/Repository'; +import PurchaseButton from './PurchaseButton'; const ActivationStep = () => { const MAX_LENGTH = 20; const [value, setValue] = useState(new URLSearchParams(window.location.search).get("key") || ""); const [nextDisabled, setNextDisabled] = useState(value.length < MAX_LENGTH); - const [error, setError] = useState(""); - const { updateFilters } = useFilter(); - const { setStepNext } = useStepper(); + const { setFilters, updateFilters } = useFilter(); + const { setStepNext, setStepLast } = useStepper(); + const { validateKey } = useActivator(); const onChange = e => { const newValue = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, MAX_LENGTH); setNextDisabled(newValue.length !== MAX_LENGTH); setValue(newValue); - setError(""); }; - const onNext = () => { - const data = { key: value }; - Repository.post("/key/validate/", data).then(resp => { + const onNext = async () => { + const data = await validateKey(value); + + if (data?.coordinates) { + setFilters(data); + setStepLast(); + } else if (data) { updateFilters(data); setStepNext(); - }).catch(e => { - setError( - e.response.status === 404 - ? "Invalid or expired activation key. Please try again." - : "Something went wrong. Please try again." - ); - }); + } }; const keyPanel = ( @@ -51,14 +48,12 @@ const ActivationStep = () => { variant="outlined" placeholder="Enter activation key..." autoComplete="off" - error={error.length > 0} - helperText={error} onChange={onChange} value={value} /> - + diff --git a/frontend/src/components/steps/activation/PurchaseButton.jsx b/frontend/src/components/steps/activation/PurchaseButton.jsx new file mode 100644 index 0000000..ceab5ce --- /dev/null +++ b/frontend/src/components/steps/activation/PurchaseButton.jsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import Button from '@mui/material/Button'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; + +const PurchaseButton = () => { + const [anchor, setAnchor] = useState(null); + const isOpen = Boolean(anchor); + + const toggleMenu = (e) => { + setAnchor(isOpen ? null : e.currentTarget); + }; + + const onSelect = (href) => { + setAnchor(null); + window.location = href; + }; + + return ( + <> + + toggleMenu(null)} + MenuListProps={{"aria-labelledby": "purchase-button"}} + > + onSelect("/api/key/purchase")}>Purchase digital copy + onSelect("/api/key/purchase?physical=true")}>Purchase physical copy + + + ); +}; + +export default PurchaseButton; diff --git a/frontend/src/components/steps/address/AddressStep.jsx b/frontend/src/components/steps/address/AddressStep.jsx deleted file mode 100644 index 47abb6b..0000000 --- a/frontend/src/components/steps/address/AddressStep.jsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState } from 'react'; -import Step from '../Step'; -import StepInformation from '../StepInformation'; -import staticText from '../../../assets/data/staticText.json' -import addressBackgroundImage from '../../../assets/img/stepBackgrounds/step1.jpg'; -import AddressSearch from './AddressSearch'; -import { StepperFooter } from '../../providers/StepperProvider'; -import { useFilter } from '../../providers/FilterProvider'; - - -const AddressStep = () => { - const [nextDisabled, setNextDisabled] = useState(true); - const { updateFilters } = useFilter(); - - const addressPanel = ( -
- {staticText.steps.address.description}

} - /> -
- { - if (address) { - setNextDisabled(false); - updateFilters({ - coordinates: { - lat: address.coordinates[1], - lng: address.coordinates[0], - }, - }); - } else { - setNextDisabled(true); - } - }}/> -
-
- ); - - return ( - <> - - - ); -}; - -export default AddressStep; diff --git a/frontend/src/components/steps/complete/CompleteStep.jsx b/frontend/src/components/steps/complete/CompleteStep.jsx deleted file mode 100644 index 59ac5eb..0000000 --- a/frontend/src/components/steps/complete/CompleteStep.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import Step from "../Step"; -import StepInformation from "../StepInformation"; -import completeBackgroundImage from "../../../assets/img/stepBackgrounds/step6.jpg"; -import staticText from "../../../assets/data/staticText.json"; -import { StepperFooter } from "../../providers/StepperProvider"; - -export default function CompleteStep() { - const completeInfoPanel = ( - {staticText.steps.complete.description}

} - /> - ); - - return ( - <> - - - - ); -}; diff --git a/frontend/src/components/steps/address/AddressSearch.jsx b/frontend/src/components/steps/location/AddressSearch.jsx similarity index 95% rename from frontend/src/components/steps/address/AddressSearch.jsx rename to frontend/src/components/steps/location/AddressSearch.jsx index 688b5dd..145ad1d 100644 --- a/frontend/src/components/steps/address/AddressSearch.jsx +++ b/frontend/src/components/steps/location/AddressSearch.jsx @@ -25,7 +25,7 @@ const AddressSearchSuggestions = ({results, onClick}) => (
: null); -const AddressSearch = ({onSelect, classNames}) => { +const AddressSearch = ({onSelect}) => { const [value, setValue] = useState(""); const [enable, setEnable] = useState(true); const [selected, setSelected] = useState(null); @@ -53,7 +53,7 @@ const AddressSearch = ({onSelect, classNames}) => { }, [selected, onSelect]); return ( -
+ { } }} value={value} + sx={{top: 0}} /> { setSelected(r); }} /> -
); + ); }; export default AddressSearch; diff --git a/frontend/src/components/steps/location/LocationStep.jsx b/frontend/src/components/steps/location/LocationStep.jsx index b5dcd13..5cde6e3 100644 --- a/frontend/src/components/steps/location/LocationStep.jsx +++ b/frontend/src/components/steps/location/LocationStep.jsx @@ -1,31 +1,57 @@ import { useState } from "react"; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; import Step from "../Step"; import LocationSelectorMap from "./Map"; import StepInformation from "../StepInformation"; import staticText from "../../../assets/data/staticText.json"; import locationBackgroundImage from "../../../assets/img/stepBackgrounds/step1.jpg"; import { StepperFooter } from '../../providers/StepperProvider'; +import AddressSearch from "./AddressSearch"; +import { useFilter } from '../../providers/FilterProvider'; -export default function LocationStep(props) { +export default function LocationStep({defaultIsSearch = false}) { const [nextDisabled, setNextDisabled] = useState(true); + const [showSearch, setShowSearch] = useState(defaultIsSearch); + const { updateFilters } = useFilter(); const locationInfoPanel = ( - {staticText.steps.location.description}

} - /> +
+ {staticText.steps.location.description}

} + /> + + + + +
); - const locationSelectionPanel = ; + const selectionComponent = showSearch + ? { + if (address) { + setNextDisabled(false); + updateFilters({ + coordinates: { + lat: address.coordinates[1], + lng: address.coordinates[0], + }, + }); + } else { + setNextDisabled(true); + } + }}/> + : ; return ( <> ); -} +}; diff --git a/frontend/src/components/steps/results/PlantList.jsx b/frontend/src/components/steps/results/PlantList.jsx index 217428b..84675d8 100644 --- a/frontend/src/components/steps/results/PlantList.jsx +++ b/frontend/src/components/steps/results/PlantList.jsx @@ -23,9 +23,8 @@ import staticText from "../../../assets/data/staticText.json"; import forestGraphic from "../../../assets/img/habitats/1a_Forest_Section.png"; import { CircularProgress } from "@mui/material"; -function TablePaginationActions(props) { +function TablePaginationActions({ count, page, rowsPerPage, onPageChange }) { const theme = useTheme(); - const { count, page, rowsPerPage, onPageChange } = props; const handleFirstPageButtonClick = (event) => { onPageChange(event, 0); @@ -95,18 +94,21 @@ TablePaginationActions.propTypes = { export default function PlantResultsTable(props) { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(25); + const isLoading = props?.rows === null; let rows = []; - if (props.rows) { + if (!isLoading) { rows = rowsPerPage > 0 ? props.rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) : props.rows; } + const totalRows = props?.rows?.length ?? 0; + // Avoid a layout jump when reaching the last page with empty rows. const emptyRows = - page > 0 ? Math.max(0, (1 + page) * rowsPerPage - props.rows.length) : 0; + page > 0 ? Math.max(0, (1 + page) * rowsPerPage - totalRows) : 0; const handleChangePage = (event, newPage) => { setPage(newPage); @@ -157,30 +159,28 @@ export default function PlantResultsTable(props) { - {rows.length === 0 && ( - - - - - - - - )} - {rows.length > 0 && - rows.map((row) => ( - - - {row.name} + {isLoading + ? + + + + - {row.growthForm} - {row.moisturePreferences} - {row.plantTolerances} - {row.ecosystemServices} - {row.carbonSequestration} - {row.plantingStage} - ))} - + : rows.map((row) => ( + + + {row.name} + + {row.growthForm} + {row.moisturePreferences} + {row.plantTolerances} + {row.ecosystemServices} + {row.carbonSequestration} + {row.plantingStage} + + )) + } {emptyRows > 0 && ( @@ -193,7 +193,7 @@ export default function PlantResultsTable(props) { className="plant-list-pagination" rowsPerPageOptions={[5, 10, 25, { label: "All", value: -1 }]} colSpan={7} - count={props.rows.length} + count={totalRows} rowsPerPage={rowsPerPage} page={page} SelectProps={{ diff --git a/frontend/src/components/steps/results/ResultsStep.jsx b/frontend/src/components/steps/results/ResultsStep.jsx index 578b416..f98eedb 100644 --- a/frontend/src/components/steps/results/ResultsStep.jsx +++ b/frontend/src/components/steps/results/ResultsStep.jsx @@ -5,10 +5,10 @@ import PlantList from "./PlantList"; import Stack from "@mui/material/Stack"; import Button from "@mui/material/Button"; import PlantRepository from "../../../repository/PlantRepository"; -import { Typography, Box } from "@mui/material"; +import { Typography, Box, Modal } from "@mui/material"; import resultsBackgroundImage from "../../../assets/img/stepBackgrounds/step6.jpg"; import staticText from "../../../assets/data/staticText.json"; -import { useFilter } from "../../providers/FilterProvider"; +import { useActivator } from "../../providers/ActivationProvider"; import { StepperFooter } from "../../providers/StepperProvider"; const RESULTS_DESCRIPTION = ( @@ -30,56 +30,32 @@ const RESULTS_DESCRIPTION = ( ); export default function ResultsStep(props) { - const [plants, setPlants] = useState([]); - const { filters } = useFilter(); + const [plants, setPlants] = useState(null); + const [showModal, setShowModal] = useState(true); + const { key } = useActivator(); + + const closeModal = () => setShowModal(false); useEffect(() => { - const updatePlants = () => { - PlantRepository.getFilteredPlants(filters) - .then((response) => { - if (response.status === 200) { - setPlants(response.data); - } - }) - .catch((e) => { - this.setState({ plants: ["No plants found."] }); - }); - }; - updatePlants(); - }, [filters]); - - function createData( - name, - growthForm, - moisturePreferences, - plantTolerances, - ecosystemServices, - carbonSequestration, - plantingStage - ) { - return { - name, - growthForm, - moisturePreferences, - plantTolerances, - ecosystemServices, - carbonSequestration, - plantingStage, - }; - } + PlantRepository.getFilteredPlants(key) + .then((response) => { + setPlants(response.status === 200 ? response.data : []); + }).catch(e => { + setPlants([]); + }); + }, [key]); const getTableRows = () => { - return plants.map((plant) => { - return createData( - plant.display_name, - plant.display_growth_form, - plant.moisture_preferences, - plant.plant_tolerances, - plant.ecosystem_services, - plant.carbon_sequestration, - plant.stage - ); - }); + // null if unloaded, empty if loaded but no results + return plants?.map((plant) => ({ + name: plant.display_name, + growthForm: plant.display_growth_form, + moisturePreferences: plant.moisture_preferences, + plantTolerances: plant.plant_tolerances, + ecosystemServices: plant.ecosystem_services, + carbonSequestration: plant.carbon_sequestration, + stage: plant.stage, + })) ?? null; }; const download = (response, fileType, fileName) => { @@ -94,13 +70,13 @@ export default function ResultsStep(props) { }; const downloadCSV = () => { - PlantRepository.getPlantsCSV(filters).then((response) => { + PlantRepository.getPlantsCSV(key).then((response) => { download(response, "text/csv", "plants.csv"); }); }; const downloadPDF = () => { - PlantRepository.getPlantsPDF(filters).then((response) => { + PlantRepository.getPlantsPDF(key).then((response) => { download(response, "application/pdf", "planting_guide.pdf"); }); }; @@ -142,6 +118,28 @@ export default function ResultsStep(props) { backgroundImage={resultsBackgroundImage} /> + + + Questionnaire Complete + You have completed the questionnaire for your chosen habitat. If you have purchased a physical copy of the planting guide, it should arrive at your chosen delivery address in a number of business days. + You may now dismiss this popup to view a table of results. You are able to access this page at any time in future by supplying the activation key used at the start of this questionnaire. + + + + + + ); } diff --git a/frontend/src/components/steps/summary/SummaryContent.jsx b/frontend/src/components/steps/summary/SummaryContent.jsx index 0ebf0f2..fa4bb0a 100644 --- a/frontend/src/components/steps/summary/SummaryContent.jsx +++ b/frontend/src/components/steps/summary/SummaryContent.jsx @@ -29,45 +29,53 @@ export default function SummaryContent() { !Object.keys(locationDetails).length && getLocationDetails(); }); - function createData(name, value) { - return { name, value }; - } - const locationData = [ - createData( - "Geographical Coordinates (latitude, longitude)", - `(${filters.coordinates.lat}, ${filters.coordinates.lng})` - ), - createData("Ecological Region", locationDetails.ecological_region || ""), - createData( - "Ecological District", - locationDetails.ecological_district || "" - ), - createData("Property Name", locationDetails.full_address || ""), + { + name: "Geographical Coordinates (latitude, longitude)", + value: `(${filters.coordinates.lat}, ${filters.coordinates.lng})`, + }, + { + name: "Ecological Region", + value: locationDetails.ecological_region || "", + }, + { + name: "Ecological District", + value: locationDetails.ecological_district || "", + }, + { + name: "Property Name", + value: locationDetails.full_address || "", + }, ]; const soilData = [ - createData( - "Soil Order", - `${locationDetails.soil_name} (${locationDetails.soil_code})` || "" - ), - createData("Soil Variant", filters.soilVariant), + { + name: "Soil Order", + value: `${locationDetails.soil_name} (${locationDetails.soil_code})` || "", + }, + { + name: "Soil Variant", + value: filters.soilVariant, + }, ]; const siteData = [ - createData("Habitat", filters.habitat.name ?? ""), - createData( - "Zone Name", - (filters.zone && filters.zone.name) ?? "" - ), - createData( - "Zone Variant", - (filters.zone && filters.zone.variant) ?? "" - ), - createData( - "Zone Refined Variant", - (filters.zone && filters.zone.refined_variant) ?? "" - ), + { + name: "Habitat", + value: filters.habitat.name ?? "", + }, + { + name: "Zone Name", + value: (filters.zone && filters.zone.name) ?? "", + }, + { + name: "Zone Variant", + value: (filters.zone && filters.zone.variant) ?? "", + }, + { + name: "Zone Refined Variant", + value: (filters.zone && filters.zone.refined_variant) ?? "", + }, ]; const regionInformation = () => { diff --git a/frontend/src/index.js b/frontend/src/index.js index ffff73c..98a31b9 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -9,17 +9,14 @@ import { FilterProvider } from "./components/providers/FilterProvider"; // Styles import "./assets/styles/main.scss"; import "bootstrap/dist/css/bootstrap.min.css"; -import ApplyPage from "./pages/ApplyPage"; +import { ActivationProvider } from "./components/providers/ActivationProvider"; +import { ErrorProvider } from "./components/providers/ErrorProvider"; const router = createBrowserRouter([ { path: "/", element: , }, - { - path: "/apply", - element: , - }, ]); const darkTheme = createTheme({ @@ -34,11 +31,15 @@ const darkTheme = createTheme({ ReactDOM.render(
- - - - - + + + + + + + + +
, document.getElementById("root") diff --git a/frontend/src/pages/ApplyPage.jsx b/frontend/src/pages/ApplyPage.jsx deleted file mode 100644 index c41020e..0000000 --- a/frontend/src/pages/ApplyPage.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from 'react'; -import { Container } from "reactstrap"; -import Header from "../components/Header"; -import ActivationStep from "../components/steps/activation/ActivationStep"; -import AddressStep from "../components/steps/address/AddressStep"; -import SoilStep from "../components/steps/soilvariant/SoilStep"; -import HabitatStep from "../components/steps/habitat/HabitatStep"; -import ZoneStep from "../components/steps/zone/ZoneStep"; -import SummaryStep from "../components/steps/summary/SummaryStep"; -import CompleteStep from "../components/steps/complete/CompleteStep"; -import { StepperWizard } from "../components/providers/StepperProvider"; -import { useFilter } from "../components/providers/FilterProvider"; -import { Box, Typography, Modal, Button } from '@mui/material'; - - -const ApplyPage = () => { - const [error, setError] = useState(""); - const { submit } = useFilter(); - - const onSubmit = async () => { - try { - await submit(); - } catch (e) { - setError("There was a problem sending data to the API. Please try again."); - throw e; - } - } - - return ( - <> - -
- - - - - - - - - - - 0} onClose={() => setError("")}> - - Error - {error} - - - - - - - ); -}; - -export default ApplyPage; diff --git a/frontend/src/pages/MainPage.jsx b/frontend/src/pages/MainPage.jsx index f56f66e..a816664 100644 --- a/frontend/src/pages/MainPage.jsx +++ b/frontend/src/pages/MainPage.jsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { Container } from "reactstrap"; import Header from "../components/Header"; +import ActivationStep from "../components/steps/activation/ActivationStep"; import LocationStep from "../components/steps/location/LocationStep"; import SoilStep from "../components/steps/soilvariant/SoilStep"; import HabitatStep from "../components/steps/habitat/HabitatStep"; @@ -7,20 +9,52 @@ import ZoneStep from "../components/steps/zone/ZoneStep"; import SummaryStep from "../components/steps/summary/SummaryStep"; import ResultsStep from "../components/steps/results/ResultsStep"; import { StepperWizard } from "../components/providers/StepperProvider"; +import { useFilter } from "../components/providers/FilterProvider"; +import { Box, Typography, Modal, Button } from '@mui/material'; +import { useActivator } from '../components/providers/ActivationProvider'; +import { useError } from '../components/providers/ErrorProvider'; const MainPage = () => { + const { isPhysicalKey } = useActivator(); + const { submit } = useFilter(); + const { error, resetError } = useError(); + return ( - -
- - - - - - - - - ); + <> + +
+ + + + + + + + + + + 0} onClose={resetError}> + + Error + {error} + + + + + + + ); }; export default MainPage; diff --git a/frontend/src/repository/LocationRepository.js b/frontend/src/repository/LocationRepository.js index bfcdb48..9cd53e8 100644 --- a/frontend/src/repository/LocationRepository.js +++ b/frontend/src/repository/LocationRepository.js @@ -1,33 +1,34 @@ import API from "./Repository"; const LocationRepsostory = { - getSoilDetails(filters) { - return API.get(`/soil/`, { params: filters }); + getSoilDetails(params) { + return API.get(`/soil/`, {params}); }, - getEcologicalDistrictDetails(filters) { - return API.get(`/ecologicaldistrict/`, { params: filters }); + getEcologicalDistrictDetails(params) { + return API.get(`/ecologicaldistrict/`, {params}); }, - getRegionDetails(filters) { - return API.get(`/region/`, { params: filters }); + getRegionDetails(params) { + return API.get(`/region/`, {params}); }, - getPropertyDetails(filters) { - return API.get(`/address/`, { params: filters }); + getPropertyDetails(params) { + return API.get(`/address/`, {params}); }, async getLocationData(filters) { + const params = { lat: filters.coordinates.lat, lng: filters.coordinates.lng }; const [ soilDetails, ecologicalDistrictDetails, propertyDetails, regionDetails, ] = await Promise.all([ - this.getSoilDetails(filters), - this.getEcologicalDistrictDetails(filters), - this.getPropertyDetails(filters), - this.getRegionDetails(filters), + this.getSoilDetails(params), + this.getEcologicalDistrictDetails(params), + this.getPropertyDetails(params), + this.getRegionDetails(params), ]); let locationData = {}; diff --git a/frontend/src/repository/PlantRepository.js b/frontend/src/repository/PlantRepository.js index 59589e4..6e39527 100644 --- a/frontend/src/repository/PlantRepository.js +++ b/frontend/src/repository/PlantRepository.js @@ -1,17 +1,17 @@ import Repository from "./Repository"; const PlantRepository = { - getFilteredPlants(filters) { - return Repository.get(`/plants/`, { params: filters }); + getFilteredPlants(key) { + return Repository.get(`/plants/`, { params: {key} }); }, - getPlantsCSV(filters) { - return Repository.get("/download/csv/", { params: filters }); + getPlantsCSV(key) { + return Repository.get("/download/csv/", { params: {key} }); }, - getPlantsPDF(filters) { + getPlantsPDF(key) { return Repository.get("/download/pdf/", { - params: filters, + params: {key}, responseType: "arraybuffer", }); }, -- 2.45.2