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

Merged
mattn merged 2 commits from matt/41-backend into main 2023-03-09 14:58:47 +13:00
12 changed files with 263 additions and 10 deletions
Showing only changes of commit 959c41c21d - Show all commits

View file

@ -8,4 +8,6 @@ gunicorn==20.1.0
pandas==1.5.3 pandas==1.5.3
pdfkit==1.0.0 pdfkit==1.0.0
PyPDF2==1.28.6 PyPDF2==1.28.6
redis==4.5.1
celery[redis]==5.2.7 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 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): class ZoneAdmin(admin.ModelAdmin):
ordering = ['name', 'variant', 'refined_variant', 'id'] ordering = ['name', 'variant', 'refined_variant', 'id']
search_fields = ['name', 'habitat__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): 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'] actions = ['export']
@admin.display(description="Address") @admin.display(description="Address")
@ -22,7 +51,7 @@ class QuestionnaireAdmin(admin.ModelAdmin):
@admin.display(description="Location") @admin.display(description="Location")
def location_display(self, obj): 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") @admin.display(description="Ecological District")
def ecological_district_display(self, obj): def ecological_district_display(self, obj):
@ -32,6 +61,11 @@ class QuestionnaireAdmin(admin.ModelAdmin):
None, 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") @admin.action(description="Export planting guides for selected questionnaires")
def export(self, request, queryset): def export(self, request, queryset):
export = models.Export.objects.create(creation_date=timezone.now()) 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.Habitat)
admin.site.register(models.Zone, ZoneAdmin) admin.site.register(models.Zone, ZoneAdmin)
admin.site.register(models.ChristchurchRegion) 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.Questionnaire, QuestionnaireAdmin)
admin.site.register(models.Export, ExportAdmin) 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 functools import cached_property
from pathlib import Path from pathlib import Path
@ -199,10 +202,39 @@ class Address(models.Model):
managed = False 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): class Questionnaire(models.Model):
location = models.PointField() location = models.PointField()
soil_variant = models.ForeignKey(SoilVariant, on_delete=models.CASCADE) soil_variant = models.ForeignKey(SoilVariant, on_delete=models.CASCADE)
zone = models.ForeignKey(Zone, on_delete=models.CASCADE) zone = models.ForeignKey(Zone, on_delete=models.CASCADE)
key = models.ForeignKey(ActivationKey, on_delete=models.PROTECT, null=True)
@property @property
def habitat(self): 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): class QuestionnaireSerializer(serializers.ModelSerializer):
soil_variant = serializers.CharField(max_length=10) soil_variant = serializers.CharField(max_length=10)
key = serializers.CharField(max_length=20, write_only=True)
class Meta: class Meta:
model = Questionnaire model = Questionnaire
@ -110,3 +111,11 @@ class QuestionnaireSerializer(serializers.ModelSerializer):
return SoilVariant.objects.get(name__startswith=value) return SoilVariant.objects.get(name__startswith=value)
except SoilVariant.DoesNotExist as e: except SoilVariant.DoesNotExist as e:
raise exceptions.ValidationError(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 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 django.dispatch import receiver
from .models import Export from .models import Export, ActivationKey, ActivationKeySet, Questionnaire
from .resource_generation_utils import storage from .resource_generation_utils import storage
@receiver(post_delete, sender=Export) @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}")) 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 import json
from django.shortcuts import get_object_or_404 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 import timezone
from django.utils.text import slugify from django.utils.text import slugify
from rest_framework import viewsets, permissions from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response 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 .serializers import HabitatImageSerializer, HabitatSerializer, PlantSerializer, SoilOrderSerializer, EcologicalDistrictLayerSerializer, AddressSerializer, ZoneSerializer, QuestionnaireSerializer
from .filters import * from .filters import *
from .wms_utils import get_address_from_coordinates, search_address 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, CSV_FILENAME, storage
from .redis import redis_client
class PlantViewSet(viewsets.ModelViewSet): class PlantViewSet(viewsets.ModelViewSet):
@ -159,3 +167,83 @@ class QuestionnaireViewSet(viewsets.ModelViewSet):
queryset = Questionnaire.objects.all() queryset = Questionnaire.objects.all()
http_method_names = ("post",) http_method_names = ("post",)
permission_classes = [permissions.AllowAny] 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 os
import stripe
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@ -150,6 +151,17 @@ CORS_ALLOW_HEADERS = [
'access-control-allow-origin' '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 configuration
CELERY_TIMEZONE = TIME_ZONE 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 = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/', include(router.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')) path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] ]

View file

@ -70,12 +70,17 @@ services:
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./backend/right_tree/staticfiles:/etc/nginx/html/staticfiles:ro - ./backend/right_tree/staticfiles:/etc/nginx/html/staticfiles:ro
ports: ports:
- "9000:80" - 80:80
redis: redis:
image: redis:7.0.8 image: redis:7.0.8
restart: unless-stopped restart: unless-stopped
container_name: redis container_name: redis
volumes:
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
command:
- redis-server
- /usr/local/etc/redis/redis.conf
expose: expose:
- "6379" - "6379"
healthcheck: healthcheck:

1
redis.conf Normal file
View file

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