[#41] Allow users to download the user/planting guide for a payment - backend #92
12 changed files with 274 additions and 20 deletions
|
@ -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
|
||||
|
|
|
@ -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', '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,20 @@ 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):
|
||||
return getattr(
|
||||
models.EcologicalDistrictLayer.objects.filter(geom__intersects=obj.location).first(),
|
||||
"ecologic_2",
|
||||
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):
|
||||
|
@ -88,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)
|
||||
|
|
40
backend/right_tree/api/migrations/0015_auto_20230306_1620.py
Normal file
40
backend/right_tree/api/migrations/0015_auto_20230306_1620.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -1,3 +1,6 @@
|
|||
import random
|
||||
import string
|
||||
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
||||
|
@ -199,23 +202,44 @@ 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):
|
||||
return self.zone.habitat
|
||||
|
||||
@property
|
||||
def ecological_district_layer(self):
|
||||
return EcologicalDistrictLayer.objects.filter(geom__intersects=self.location).first()
|
||||
|
||||
@property
|
||||
def ecological_district(self):
|
||||
return self.ecological_district_layer.ecologic_2
|
||||
|
||||
@cached_property
|
||||
def address(self):
|
||||
return \
|
||||
|
@ -264,8 +288,9 @@ class Export(models.Model):
|
|||
|
||||
_, files = storage.listdir(f"export_{self.pk}")
|
||||
|
||||
# halved as there are two files (csv, pdf) per questionnaire
|
||||
return 0.5 * len(files) / self.questionnaires.count()
|
||||
# 0.25 multiplier as there are four files per questionnaire:
|
||||
# csv, filters PDF, plants PDF, merged PDF
|
||||
return 0.25 * len(files) / self.questionnaires.count()
|
||||
|
||||
def export(self):
|
||||
from .tasks import generate_pdf
|
||||
|
|
6
backend/right_tree/api/redis.py
Normal file
6
backend/right_tree/api/redis.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import redis
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
redis_client = redis.from_url(settings.REDIS_DJANGO_URL)
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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'))
|
||||
]
|
||||
|
|
|
@ -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
1
redis.conf
Normal file
|
@ -0,0 +1 @@
|
|||
requirepass "redis"
|
Loading…
Reference in a new issue