[#41] Allow users to download the user/planting guide for a payment - backend #92
12 changed files with 263 additions and 10 deletions
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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 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):
|
||||||
|
|
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):
|
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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -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'))
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
1
redis.conf
Normal file
|
@ -0,0 +1 @@
|
||||||
|
requirepass "redis"
|
Loading…
Reference in a new issue