[#41] Allow users to download the user/planting guide for a payment

- backend changes
This commit is contained in:
Matthew Northcott 2023-03-06 18:10:25 +13:00
parent 1b800ff8ef
commit 959c41c21d
12 changed files with 263 additions and 10 deletions

View file

@ -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

View file

@ -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)

View file

@ -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'),
),
]

View file

@ -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):

View file

@ -0,0 +1,6 @@
import redis
from django.conf import settings
redis_client = redis.from_url(settings.REDIS_DJANGO_URL)

View file

@ -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)

View file

@ -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()

View file

@ -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)

View file

@ -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']

View file

@ -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'))
]

View file

@ -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:

1
redis.conf Normal file
View file

@ -0,0 +1 @@
requirepass "redis"