From 959c41c21d83557ceae565da7ac07f52b1a20d11 Mon Sep 17 00:00:00 2001 From: Matthew Northcott Date: Mon, 6 Mar 2023 18:10:25 +1300 Subject: [PATCH] [#41] Allow users to download the user/planting guide for a payment - backend changes --- backend/requirements.txt | 2 + backend/right_tree/api/admin.py | 40 +++++++- .../api/migrations/0015_auto_20230306_1620.py | 40 ++++++++ backend/right_tree/api/models.py | 32 +++++++ backend/right_tree/api/redis.py | 6 ++ backend/right_tree/api/serializers.py | 9 ++ backend/right_tree/api/signals.py | 25 ++++- backend/right_tree/api/views.py | 94 ++++++++++++++++++- backend/right_tree/settings.py | 14 ++- backend/right_tree/urls.py | 3 + docker-compose.yaml | 7 +- redis.conf | 1 + 12 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 backend/right_tree/api/migrations/0015_auto_20230306_1620.py create mode 100644 backend/right_tree/api/redis.py create mode 100644 redis.conf diff --git a/backend/requirements.txt b/backend/requirements.txt index e23b063..665e255 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,6 @@ gunicorn==20.1.0 pandas==1.5.3 pdfkit==1.0.0 PyPDF2==1.28.6 +redis==4.5.1 celery[redis]==5.2.7 +stripe==5.2.0 diff --git a/backend/right_tree/api/admin.py b/backend/right_tree/api/admin.py index a6f58f0..7c017de 100644 --- a/backend/right_tree/api/admin.py +++ b/backend/right_tree/api/admin.py @@ -7,13 +7,42 @@ from right_tree.api import models from right_tree.api.resource_generation_utils import storage +class ActivationKeySetFilter(admin.SimpleListFilter): + title = "key set" + parameter_name = "key_set" + + def lookups(self, request, model_admin): + return [ + (val, val) for val in models.ActivationKeySet.objects.values_list('name', flat=True) + ] + + def queryset(self, request, queryset): + key_set = self.value() + return queryset.filter(key__key_set__name=key_set) if key_set else queryset + + class ZoneAdmin(admin.ModelAdmin): ordering = ['name', 'variant', 'refined_variant', 'id'] search_fields = ['name', 'habitat__name', 'variant', 'refined_variant', 'id'] +class ActivationKeySetAdmin(admin.ModelAdmin): + list_display = ['name', 'size_display'] + + @admin.display(description="Size") + def size_display(self, obj): + size = obj.size + return size if size > 0 else None + + +class ActivationKeyAdmin(admin.ModelAdmin): + list_display = ['key', 'creation_date', 'key_set', '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'] + list_display = ['address_display', 'location_display', 'soil_variant', 'ecological_district_display', 'habitat', 'zone', 'key_set_display'] + list_filter = [ActivationKeySetFilter] actions = ['export'] @admin.display(description="Address") @@ -22,7 +51,7 @@ class QuestionnaireAdmin(admin.ModelAdmin): @admin.display(description="Location") def location_display(self, obj): - return f"({obj.location.x}, {obj.location.y})" + return f"({obj.location.x:.4f}, {obj.location.y:.4f})" @admin.display(description="Ecological District") def ecological_district_display(self, obj): @@ -32,6 +61,11 @@ class QuestionnaireAdmin(admin.ModelAdmin): None, ) + @admin.display(description="Key Set") + def key_set_display(self, obj): + if obj.key and obj.key.key_set: + return obj.key.key_set.name + @admin.action(description="Export planting guides for selected questionnaires") def export(self, request, queryset): export = models.Export.objects.create(creation_date=timezone.now()) @@ -96,5 +130,7 @@ admin.site.register(models.HabitatImage) admin.site.register(models.Habitat) admin.site.register(models.Zone, ZoneAdmin) admin.site.register(models.ChristchurchRegion) +admin.site.register(models.ActivationKey, ActivationKeyAdmin) +admin.site.register(models.ActivationKeySet, ActivationKeySetAdmin) admin.site.register(models.Questionnaire, QuestionnaireAdmin) admin.site.register(models.Export, ExportAdmin) diff --git a/backend/right_tree/api/migrations/0015_auto_20230306_1620.py b/backend/right_tree/api/migrations/0015_auto_20230306_1620.py new file mode 100644 index 0000000..d8d3dcc --- /dev/null +++ b/backend/right_tree/api/migrations/0015_auto_20230306_1620.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.17 on 2023-03-06 03:20 + +from django.db import migrations, models +import django.db.models.deletion +import right_tree.api.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_address_export_questionnaire'), + ] + + operations = [ + migrations.CreateModel( + name='ActivationKeySet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('size', models.PositiveSmallIntegerField()), + ('initial_activations', models.SmallIntegerField(default=1)), + ], + ), + migrations.CreateModel( + name='ActivationKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(default=right_tree.api.models.ActivationKey.key_default, max_length=20, unique=True)), + ('remaining_activations', models.SmallIntegerField(default=1)), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('key_set', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.activationkeyset')), + ], + ), + migrations.AddField( + model_name='questionnaire', + name='key', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='api.activationkey'), + ), + ] diff --git a/backend/right_tree/api/models.py b/backend/right_tree/api/models.py index 1d23ebc..8814c35 100644 --- a/backend/right_tree/api/models.py +++ b/backend/right_tree/api/models.py @@ -1,3 +1,6 @@ +import random +import string + from functools import cached_property from pathlib import Path @@ -199,10 +202,39 @@ class Address(models.Model): managed = False +class ActivationKeySet(models.Model): + creation_date = models.DateTimeField(auto_now_add=True) + name = models.CharField(max_length=255, unique=True) + size = models.PositiveSmallIntegerField() + initial_activations = models.SmallIntegerField(default=1) + + def __str__(self): + return self.name + + +class ActivationKey(models.Model): + + def key_default(): + return "".join( + random.choice( + [string.ascii_uppercase, string.digits][random.randint(0, 1)] + ) for _ in range(20) + ) + + key = models.CharField(max_length=20, unique=True, default=key_default) + key_set = models.ForeignKey(ActivationKeySet, on_delete=models.CASCADE, null=True) + remaining_activations = models.SmallIntegerField(default=1) + creation_date = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.key + + class Questionnaire(models.Model): location = models.PointField() 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) @property def habitat(self): diff --git a/backend/right_tree/api/redis.py b/backend/right_tree/api/redis.py new file mode 100644 index 0000000..f9ddf14 --- /dev/null +++ b/backend/right_tree/api/redis.py @@ -0,0 +1,6 @@ +import redis + +from django.conf import settings + + +redis_client = redis.from_url(settings.REDIS_DJANGO_URL) diff --git a/backend/right_tree/api/serializers.py b/backend/right_tree/api/serializers.py index 5520093..fd49f4a 100644 --- a/backend/right_tree/api/serializers.py +++ b/backend/right_tree/api/serializers.py @@ -100,6 +100,7 @@ class AddressSerializer(serializers.Serializer): class QuestionnaireSerializer(serializers.ModelSerializer): soil_variant = serializers.CharField(max_length=10) + key = serializers.CharField(max_length=20, write_only=True) class Meta: model = Questionnaire @@ -110,3 +111,11 @@ class QuestionnaireSerializer(serializers.ModelSerializer): return SoilVariant.objects.get(name__startswith=value) except SoilVariant.DoesNotExist as e: raise exceptions.ValidationError(e) + + def validate_key(self, value): + try: + if (ak := ActivationKey.objects.get(key=value)).remaining_activations > 0: + return ak + raise exceptions.ValidationError("no remaining activations") + except ActivationKey.DoesNotExist as e: + raise exceptions.ValidationError(e) diff --git a/backend/right_tree/api/signals.py b/backend/right_tree/api/signals.py index 59feee3..5b7bed4 100644 --- a/backend/right_tree/api/signals.py +++ b/backend/right_tree/api/signals.py @@ -1,12 +1,31 @@ from shutil import rmtree -from django.db.models.signals import post_delete +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver -from .models import Export +from .models import Export, ActivationKey, ActivationKeySet, Questionnaire from .resource_generation_utils import storage @receiver(post_delete, sender=Export) -def delete_export(sender, instance, *args, **kwargs): +def delete_export(sender, instance, created, *args, **kwargs): + """Clean up created files on the filesystem when an export is deleted""" rmtree(storage.path(f"export_{instance.pk}")) + + +@receiver(post_save, sender=ActivationKeySet) +def create_keys(sender, instance, created, *args, **kwargs): + """Create n ActivationKey objects where n is the ActivationKeySet.size value""" + if created: + ActivationKey.objects.bulk_create([ + ActivationKey(key_set=instance, remaining_activations=instance.initial_activations) + for _ in range(instance.size) + ]) + + +@receiver(post_save, sender=Questionnaire) +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.save() diff --git a/backend/right_tree/api/views.py b/backend/right_tree/api/views.py index 1d772d4..8b0a7b1 100644 --- a/backend/right_tree/api/views.py +++ b/backend/right_tree/api/views.py @@ -1,15 +1,23 @@ -from django.http import HttpResponseBadRequest, FileResponse -from django.shortcuts import get_object_or_404 +import json +import stripe + +from datetime import timedelta + +from django.http import HttpResponse, 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 +from .models import Habitat, HabitatImage, Plant, EcologicalDistrictLayer, SoilOrder, Zone, Questionnaire, ActivationKey, ActivationKeySet 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 .redis import redis_client class PlantViewSet(viewsets.ModelViewSet): @@ -159,3 +167,83 @@ class QuestionnaireViewSet(viewsets.ModelViewSet): queryset = Questionnaire.objects.all() http_method_names = ("post",) permission_classes = [permissions.AllowAny] + + +def validate_key(request): + """Checks if a given key value is valid""" + if request.method == "GET": + data = request.GET + elif request.method != "POST": + return HttpResponseNotAllowed() + else: + try: + data = request.POST or json.loads(request.body) + except json.JSONDecodeError as e: + return HttpResponseBadRequest(e) + + key = data.get("key") + + if not key: + return HttpResponseBadRequest("'key' not specified") + + try: + if ActivationKey.objects.get(key=key).remaining_activations > 0: + return HttpResponse() + except ActivationKey.DoesNotExist: + pass + + return HttpResponseNotFound() + + +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) + + 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) + + return redirect(redirect_url) + + +def purchase_key(request): + """Generate a prospective key and redirect to the Stripe payment portal""" + key = ActivationKey.key_default() + redirect_url = request.build_absolute_uri(reverse(activate_key)) + f"?key={key}" + stripe_session = stripe.checkout.Session.create( + line_items=[ + { + "price": "price_1Mh1I6GLlkkooLVio8W3TGkR", + "quantity": 1, + }, + ], + automatic_tax={'enabled': True}, + invoice_creation={ + 'enabled': True, + 'invoice_data': { + 'description': f'Your product code is {key}', + 'rendering_options': {'amount_tax_display': 'include_inclusive_tax'}, + 'footer': 'BioSphere Capital Limited', + }, + }, + mode='payment', + success_url=redirect_url, + cancel_url=redirect_url, + ) + redis_client.setex(key, timedelta(hours=8), stripe_session.id) + + return redirect(stripe_session.url) diff --git a/backend/right_tree/settings.py b/backend/right_tree/settings.py index 8f43ed0..a1f4ff8 100644 --- a/backend/right_tree/settings.py +++ b/backend/right_tree/settings.py @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/3.2/ref/settings/ """ import os +import stripe from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -150,6 +151,17 @@ CORS_ALLOW_HEADERS = [ 'access-control-allow-origin' ] +# Redis configuration +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PORT = int(os.getenv("REDIS_PORT", 6379)) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "redis") +REDIS_BASE_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}" +REDIS_CELERY_URL = REDIS_BASE_URL + "/0" +REDIS_DJANGO_URL = REDIS_BASE_URL + "/1" + # Celery configuration CELERY_TIMEZONE = TIME_ZONE -CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") +CELERY_BROKER_URL = REDIS_CELERY_URL + +# Stripe payment processing +stripe.api_key = os.environ['STRIPE_API_KEY'] diff --git a/backend/right_tree/urls.py b/backend/right_tree/urls.py index daa0743..1985f56 100644 --- a/backend/right_tree/urls.py +++ b/backend/right_tree/urls.py @@ -36,5 +36,8 @@ router.register(r'download/pdf', views.PDFDownloadView, basename='downloadpdf') urlpatterns = [ path('admin/', admin.site.urls), path('api/', include(router.urls)), + path('api/key/validate/', views.validate_key), + path('api/key/activate/', views.activate_key), + path('api/key/purchase/', views.purchase_key), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) ] diff --git a/docker-compose.yaml b/docker-compose.yaml index fb0ce54..bfabfc7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -70,12 +70,17 @@ services: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./backend/right_tree/staticfiles:/etc/nginx/html/staticfiles:ro ports: - - "9000:80" + - 80:80 redis: image: redis:7.0.8 restart: unless-stopped container_name: redis + volumes: + - ./redis.conf:/usr/local/etc/redis/redis.conf:ro + command: + - redis-server + - /usr/local/etc/redis/redis.conf expose: - "6379" healthcheck: diff --git a/redis.conf b/redis.conf new file mode 100644 index 0000000..de7c76e --- /dev/null +++ b/redis.conf @@ -0,0 +1 @@ +requirepass "redis"