Compare commits
3 commits
68c5199bb5
...
a51a7e2099
Author | SHA1 | Date | |
---|---|---|---|
a51a7e2099 | |||
48048f349a | |||
c843e1c59f |
8 changed files with 108 additions and 45 deletions
|
@ -3,6 +3,7 @@ from django.utils.safestring import mark_safe
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from .models import SustainerOrder
|
from .models import SustainerOrder
|
||||||
|
|
||||||
|
|
||||||
class SustainerFormRenderer(forms.renderers.DjangoTemplates):
|
class SustainerFormRenderer(forms.renderers.DjangoTemplates):
|
||||||
# Customised layout with labels on own row
|
# Customised layout with labels on own row
|
||||||
field_template_name = 'supporters/field.html'
|
field_template_name = 'supporters/field.html'
|
||||||
|
@ -10,6 +11,7 @@ class SustainerFormRenderer(forms.renderers.DjangoTemplates):
|
||||||
|
|
||||||
class ButtonRadioSelect(forms.widgets.RadioSelect):
|
class ButtonRadioSelect(forms.widgets.RadioSelect):
|
||||||
"""Radio button styled like a button. BYO CSS."""
|
"""Radio button styled like a button. BYO CSS."""
|
||||||
|
|
||||||
# Extra <span> wrappers to support CSS
|
# Extra <span> wrappers to support CSS
|
||||||
option_template_name = 'supporters/buttonradio_option.html'
|
option_template_name = 'supporters/buttonradio_option.html'
|
||||||
use_fieldset = False
|
use_fieldset = False
|
||||||
|
@ -52,11 +54,23 @@ class SustainerForm(forms.ModelForm):
|
||||||
'country',
|
'country',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'recurring': ButtonRadioSelect(attrs={
|
'recurring': ButtonRadioSelect(
|
||||||
'x-model': 'recurring',
|
attrs={
|
||||||
# Reset the amount field and option when changing monthly/annually.
|
'x-model': 'recurring',
|
||||||
'x-on:change': '$refs.amount.value = null; amount_option = null',
|
# Reset the amount field and option when changing monthly/annually.
|
||||||
}),
|
'x-on:change': 'amount = ""; amount_option = null',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
'amount': forms.widgets.NumberInput(
|
||||||
|
# Keeping default widget, just neater to add many attrs here.
|
||||||
|
attrs={
|
||||||
|
# So we can update the amount field from the amount_option selected.
|
||||||
|
'x-model': 'amount',
|
||||||
|
'x-bind:min': 'amount_minimum',
|
||||||
|
'onblur': 'this.reportValidity()',
|
||||||
|
'style': 'width: 5rem',
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -66,13 +80,6 @@ class SustainerForm(forms.ModelForm):
|
||||||
|
|
||||||
self.fields['recurring'].label = ''
|
self.fields['recurring'].label = ''
|
||||||
self.fields['amount'].initial = self.YEAR_OPTIONS[0]
|
self.fields['amount'].initial = self.YEAR_OPTIONS[0]
|
||||||
# So we can write to this field easily from Alpine JS.
|
|
||||||
self.fields['amount'].widget.attrs['x-ref'] = 'amount'
|
|
||||||
self.fields['amount'].widget.attrs['style'] = 'width: 5rem'
|
|
||||||
self.fields['amount'].widget.attrs['onblur'] = 'this.reportValidity()'
|
|
||||||
self.fields['amount'].widget.attrs['x-bind:min'] = 'amount_minimum'
|
|
||||||
self.fields['email'].help_text = 'For your payment receipt'
|
|
||||||
self.fields['tshirt_size'].help_text = mark_safe("""Sizing chart: <a href="/videos/women-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Women's</a>, <a href="/videos/men-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Men's</a>""")
|
|
||||||
self.fields['tshirt_size'].widget.attrs['x-model'] = 'tshirt_size'
|
self.fields['tshirt_size'].widget.attrs['x-model'] = 'tshirt_size'
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
@ -84,15 +91,16 @@ class SustainerForm(forms.ModelForm):
|
||||||
if amount < minimum:
|
if amount < minimum:
|
||||||
self.add_error(
|
self.add_error(
|
||||||
'',
|
'',
|
||||||
mark_safe(f'${minimum:d} is a minimum for Conservancy Sustainers. <a href="{donate_url}">Donate smaller amounts here</a>.')
|
mark_safe(
|
||||||
|
f'${minimum:d} is a minimum for Conservancy Sustainers. <a href="{donate_url}">Donate smaller amounts here</a>.'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
tshirt_size = self.cleaned_data.get('tshirt_size')
|
tshirt_size = self.cleaned_data.get('tshirt_size')
|
||||||
if tshirt_size and not all([
|
if tshirt_size and not all(
|
||||||
|
[
|
||||||
self.cleaned_data.get('street'),
|
self.cleaned_data.get('street'),
|
||||||
self.cleaned_data.get('city'),
|
self.cleaned_data.get('city'),
|
||||||
self.cleaned_data.get('country')
|
self.cleaned_data.get('country'),
|
||||||
]):
|
]
|
||||||
self.add_error(
|
):
|
||||||
'street',
|
self.add_error('street', 'No address provided')
|
||||||
'No address provided'
|
|
||||||
)
|
|
||||||
|
|
17
conservancy/supporters/mail.py
Normal file
17
conservancy/supporters/mail.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
def make_stripe_email(order) -> EmailMessage:
|
||||||
|
subject = 'Thanks for your sustainer payment!'
|
||||||
|
email_body = render_to_string(
|
||||||
|
'supporters/mail/sustainer_thanks.txt',
|
||||||
|
{'order': order},
|
||||||
|
).strip()
|
||||||
|
message = EmailMessage(
|
||||||
|
subject,
|
||||||
|
email_body,
|
||||||
|
'Software Freedom Conservancy <sustainers@sfconservancy.org>',
|
||||||
|
[order.email],
|
||||||
|
)
|
||||||
|
return message
|
|
@ -5,7 +5,9 @@ class Supporter(models.Model):
|
||||||
"""Conservancy Supporter listing"""
|
"""Conservancy Supporter listing"""
|
||||||
|
|
||||||
display_name = models.CharField(max_length=200, blank=False)
|
display_name = models.CharField(max_length=200, blank=False)
|
||||||
display_until_date = models.DateTimeField("date until which this supporter name is displayed")
|
display_until_date = models.DateTimeField(
|
||||||
|
"date until which this supporter name is displayed"
|
||||||
|
)
|
||||||
ledger_entity_id = models.CharField(max_length=200, blank=False)
|
ledger_entity_id = models.CharField(max_length=200, blank=False)
|
||||||
|
|
||||||
def test(self):
|
def test(self):
|
||||||
|
@ -65,13 +67,17 @@ class SustainerOrder(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
email = models.EmailField()
|
email = models.EmailField()
|
||||||
amount = models.PositiveIntegerField()
|
amount = models.PositiveIntegerField()
|
||||||
recurring = models.CharField(max_length=10, choices=RENEW_CHOICES, blank=True, default='')
|
recurring = models.CharField(
|
||||||
|
max_length=10, choices=RENEW_CHOICES, blank=True, default=''
|
||||||
|
)
|
||||||
payment_method = models.CharField(max_length=10, default='Stripe')
|
payment_method = models.CharField(max_length=10, default='Stripe')
|
||||||
payment_id = models.CharField(max_length=255, blank=True)
|
payment_id = models.CharField(max_length=255, blank=True)
|
||||||
paid_time = models.DateTimeField(null=True, blank=True)
|
paid_time = models.DateTimeField(null=True, blank=True)
|
||||||
acknowledge_publicly = models.BooleanField(default=True)
|
acknowledge_publicly = models.BooleanField(default=True)
|
||||||
add_to_mailing_list = models.BooleanField(default=True)
|
add_to_mailing_list = models.BooleanField(default=True)
|
||||||
tshirt_size = models.CharField('T-shirt size', max_length=50, choices=TSHIRT_CHOICES, blank=True, default='')
|
tshirt_size = models.CharField(
|
||||||
|
'T-shirt size', max_length=50, choices=TSHIRT_CHOICES, blank=True, default=''
|
||||||
|
)
|
||||||
street = models.CharField(max_length=255, blank=True)
|
street = models.CharField(max_length=255, blank=True)
|
||||||
city = models.CharField(max_length=255, blank=True)
|
city = models.CharField(max_length=255, blank=True)
|
||||||
state = models.CharField(max_length=255, blank=True)
|
state = models.CharField(max_length=255, blank=True)
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
<!-- Custom <span> wrapper around the label to enable radio fields to be styled like buttons. -->
|
{# Custom <span> wrapper around the label to enable radio fields to be styled like buttons. #}
|
||||||
{% if widget.wrap_label %}<label onclick="click()"{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} <span>{{ widget.label }}</span></label>{% endif %}
|
{% if widget.wrap_label %}<label onclick="click()"{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} <span>{{ widget.label }}</span></label>{% endif %}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<!-- Labels on a separate line, custom help text layout -->
|
{# Labels on a separate line, custom help text layout #}
|
||||||
{% if field.use_fieldset %}
|
{% if field.use_fieldset %}
|
||||||
<fieldset{% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
|
<fieldset{% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
|
||||||
{% if field.label %}{{ field.legend_tag }}{% endif %}
|
{% if field.label %}{{ field.legend_tag }}{% endif %}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
Hi {{ order.name }},
|
||||||
|
|
||||||
|
Thanks so much for being a sustainer! Your support is what makes our work possible.
|
||||||
|
|
||||||
|
Order: #{{ order.id }}
|
||||||
|
Payment: ${{ order.amount }}{% if order.recurring %} {{ order.get_recurring_display }}{% endif %}
|
||||||
|
Acknowledge me on the list of sustainers: {{ order.acknowledge_publicly|yesno }}
|
||||||
|
Add me to the announcements email list: {{ order.add_to_mailing_list|yesno }}
|
||||||
|
T-shirt: {{ order.get_tshirt_size_display }}{% if order.tshirt_size %}
|
||||||
|
Postal address:
|
||||||
|
{{ order.street }}
|
||||||
|
{{ order.city }} {{ order.state }} {{ order.zip_code }}
|
||||||
|
{{ order.country }}{% endif %}
|
||||||
|
{% if order.recurring == 'month' and order.tshirt_size %}
|
||||||
|
Please note that you may not receive the T-shirt until you've paid at least $60.{% endif %}
|
||||||
|
|
||||||
|
Kind regards,
|
||||||
|
Software Freedom Conservancy
|
|
@ -52,8 +52,9 @@
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<!-- Alpine JS is used to show different payments amounts for monthly/annual, write the selected payment amount into the "amount" field, reset the seleted amount when you change monthly/annual and pop out the address when you select a T-shirt. -->
|
{# Alpine JS is used to show different payments amounts for monthly/annual, write the selected payment amount into the "amount" field, reset the seleted amount when you change monthly/annual and pop out the address when you select a T-shirt. #}
|
||||||
<form method="post" action="."
|
<form method="post" action="."
|
||||||
|
{# Pre-fill field defaults in case of server-side validation error. Otherwise Alpine JS will override them. #}
|
||||||
x-data="{
|
x-data="{
|
||||||
recurring: '{{ form.recurring.value|escapejs }}',
|
recurring: '{{ form.recurring.value|escapejs }}',
|
||||||
amount: parseInt('{{ form.amount.value|escapejs }}'),
|
amount: parseInt('{{ form.amount.value|escapejs }}'),
|
||||||
|
@ -82,34 +83,40 @@
|
||||||
<template x-for="m in amount_options">
|
<template x-for="m in amount_options">
|
||||||
{# Additional click handler ensures a click-drag activates the radio (similar to a real button). #}
|
{# Additional click handler ensures a click-drag activates the radio (similar to a real button). #}
|
||||||
<label onclick="this.click()">
|
<label onclick="this.click()">
|
||||||
{# It seems to be important that all radios have a unique value to avoid UI glitches. #}
|
{# All radios have a unique value to avoid UI glitches (even though the value isn't actually used). #}
|
||||||
<input type="radio" name="amount_option" x-bind:value="m" x-on:change="$refs.amount.value = m" x-model="amount_option" required>
|
<input type="radio" name="amount_option" x-bind:value="m" x-on:change="amount = m" x-model="amount_option" required>
|
||||||
<span>$<span x-text="m.toLocaleString()"></span></span>
|
<span>$<span x-text="m.toLocaleString()"></span></span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
<!-- Hide if no JS -->
|
<!-- Hide if no JS -->
|
||||||
<template x-if="true">
|
<template x-if="true">
|
||||||
<label onclick="this.click()">
|
<label onclick="this.click()">
|
||||||
<input type="radio" name="amount_option" value="other" x-on:change="$refs.amount.value = ''" x-model="amount_option" required>
|
<input type="radio" name="amount_option" value="other" x-on:change="amount = ''" x-model="amount_option" required>
|
||||||
<span>Other</span>
|
<span>Other</span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt2" x-show="amount_option === 'other'">
|
<div class="mt2" x-show="amount_option === 'other'">
|
||||||
{{ form.amount.as_field_group }}
|
{{ form.amount.as_field_group }}
|
||||||
|
<p class="f7 black-60 mt1">Minimum $<span x-text="amount_minimum"></span>. <a href="/donate" class="black-60">Donate smaller amounts here</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt3">{{ form.name.as_field_group }}</div>
|
<div class="mt3">{{ form.name.as_field_group }}</div>
|
||||||
|
|
||||||
<div class="mt2">{{ form.email.as_field_group }}</div>
|
<div class="mt2">
|
||||||
|
{{ form.email.as_field_group }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt3"><label class="lh-title">{{ form.acknowledge_publicly }} Acknowledge me on the <a href="/sponsors#sustainers" target="_blank">list of sustainers</a></label></div>
|
<div class="mt3"><label class="lh-title">{{ form.acknowledge_publicly }} Acknowledge me on the <a href="/sponsors#sustainers" target="_blank">list of sustainers</a></label></div>
|
||||||
|
|
||||||
<div class="mt3"><label class="lh-title">{{ form.add_to_mailing_list }} Add me to the <a href="https://lists.sfconservancy.org/pipermail/announce/">announcements</a> email list</label></div>
|
<div class="mt3"><label class="lh-title">{{ form.add_to_mailing_list }} Add me to the <a href="https://lists.sfconservancy.org/pipermail/announce/">announcements email list</a></label></div>
|
||||||
|
|
||||||
<div class="mt3">
|
<div class="mt3">
|
||||||
{{ form.tshirt_size.as_field_group }}
|
{{ form.tshirt_size.as_field_group }}
|
||||||
|
<p class="f7 black-60 mt1">Sizing chart:
|
||||||
|
<a href="/videos/women-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Women's</a>,
|
||||||
|
<a href="/videos/men-2017-to-2020-t-shirt-sizing.jpg" target="_blank" class="black-60">Men's</a></p>
|
||||||
<figure class="mt2">
|
<figure class="mt2">
|
||||||
<img src="/static/img/tshirt-2023.png" alt="Software Freedom Conservancy T-shirt" width="200">
|
<img src="/static/img/tshirt-2023.png" alt="Software Freedom Conservancy T-shirt" width="200">
|
||||||
</figure>
|
</figure>
|
||||||
|
@ -130,7 +137,7 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="f7 mt3">Credit card and ACH payments are processed with Stripe. We also accept payment by PayPal, paper check and wire transfer (see below). Our sustainer program has a minimum of $120 USD per year, but we also accept <a href="/donate/">donations of smaller amounts</a>.</p>
|
<p class="f7 mt3">Credit card and ACH payments are processed with Stripe. We also accept payment by PayPal, paper check and wire transfer.</p>
|
||||||
|
|
||||||
<details id="paypal">
|
<details id="paypal">
|
||||||
<summary class="f6">PayPal</summary>
|
<summary class="f6">PayPal</summary>
|
||||||
|
|
|
@ -8,11 +8,12 @@ from django.utils import timezone
|
||||||
import stripe
|
import stripe
|
||||||
|
|
||||||
from .. import ParameterValidator
|
from .. import ParameterValidator
|
||||||
from . import forms
|
from . import forms, mail
|
||||||
from .models import Supporter, SustainerOrder
|
from .models import Supporter, SustainerOrder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def sustainers(request):
|
def sustainers(request):
|
||||||
with ParameterValidator(request.GET, 'upgrade_id') as validator:
|
with ParameterValidator(request.GET, 'upgrade_id') as validator:
|
||||||
try:
|
try:
|
||||||
|
@ -36,17 +37,19 @@ def sponsors(request):
|
||||||
"""
|
"""
|
||||||
supporters = Supporter.objects.all().filter(display_until_date__gte=datetime.now())
|
supporters = Supporter.objects.all().filter(display_until_date__gte=datetime.now())
|
||||||
supporters_count = len(supporters)
|
supporters_count = len(supporters)
|
||||||
anonymous_count = len(supporters.filter(display_name='Anonymous'))
|
anonymous_count = len(supporters.filter(display_name='Anonymous'))
|
||||||
supporters = supporters.exclude(display_name='Anonymous').order_by('ledger_entity_id')
|
supporters = supporters.exclude(display_name='Anonymous').order_by('ledger_entity_id')
|
||||||
c = {
|
c = {
|
||||||
'supporters' : supporters,
|
'supporters': supporters,
|
||||||
'supporters_count' : supporters_count,
|
'supporters_count': supporters_count,
|
||||||
'anonymous_count' : anonymous_count
|
'anonymous_count': anonymous_count,
|
||||||
}
|
}
|
||||||
return render(request, "supporters/sponsors.html", c)
|
return render(request, "supporters/sponsors.html", c)
|
||||||
|
|
||||||
|
|
||||||
def create_checkout_session(reference_id, email: str, amount: int, recurring: str, base_url: str):
|
def create_checkout_session(
|
||||||
|
reference_id, email: str, amount: int, recurring: str, base_url: str
|
||||||
|
):
|
||||||
# https://docs.stripe.com/payments/accept-a-payment
|
# https://docs.stripe.com/payments/accept-a-payment
|
||||||
# https://docs.stripe.com/api/checkout/sessions
|
# https://docs.stripe.com/api/checkout/sessions
|
||||||
YOUR_DOMAIN = base_url
|
YOUR_DOMAIN = base_url
|
||||||
|
@ -85,7 +88,9 @@ def sustainers_stripe(request):
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
order = form.save()
|
order = form.save()
|
||||||
base_url = f'{request.scheme}://{request.get_host()}'
|
base_url = f'{request.scheme}://{request.get_host()}'
|
||||||
stripe_checkout_url = create_checkout_session(order.id, order.email, order.amount, order.recurring, base_url)
|
stripe_checkout_url = create_checkout_session(
|
||||||
|
order.id, order.email, order.amount, order.recurring, base_url
|
||||||
|
)
|
||||||
return redirect(stripe_checkout_url)
|
return redirect(stripe_checkout_url)
|
||||||
else:
|
else:
|
||||||
form = forms.SustainerForm()
|
form = forms.SustainerForm()
|
||||||
|
@ -121,7 +126,9 @@ def fulfill_checkout(session_id):
|
||||||
# Checkout Session
|
# Checkout Session
|
||||||
logger.info(f'Session ID {session_id} PAID!')
|
logger.info(f'Session ID {session_id} PAID!')
|
||||||
try:
|
try:
|
||||||
order = SustainerOrder.objects.get(id=checkout_session['client_reference_id'], paid_time=None)
|
order = SustainerOrder.objects.get(
|
||||||
|
id=checkout_session['client_reference_id'], paid_time=None
|
||||||
|
)
|
||||||
order.paid_time = timezone.now()
|
order.paid_time = timezone.now()
|
||||||
if checkout_session['payment_intent']:
|
if checkout_session['payment_intent']:
|
||||||
# Payments get a payment intent directly
|
# Payments get a payment intent directly
|
||||||
|
@ -131,6 +138,8 @@ def fulfill_checkout(session_id):
|
||||||
order.payment_id = checkout_session['invoice']['payment_intent']
|
order.payment_id = checkout_session['invoice']['payment_intent']
|
||||||
order.save()
|
order.save()
|
||||||
logger.info(f'Marked sustainer order {order.id} (order.email) as paid')
|
logger.info(f'Marked sustainer order {order.id} (order.email) as paid')
|
||||||
|
email = mail.make_stripe_email(order)
|
||||||
|
email.send()
|
||||||
except SustainerOrder.DoesNotExist:
|
except SustainerOrder.DoesNotExist:
|
||||||
logger.info('No action')
|
logger.info('No action')
|
||||||
|
|
||||||
|
@ -151,9 +160,7 @@ def webhook(request):
|
||||||
logger.warning('Missing STRIPE_ENDPOINT_SECRET')
|
logger.warning('Missing STRIPE_ENDPOINT_SECRET')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = stripe.Webhook.construct_event(
|
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
|
||||||
payload, sig_header, endpoint_secret
|
|
||||||
)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Invalid payload
|
# Invalid payload
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
@ -162,8 +169,8 @@ def webhook(request):
|
||||||
return HttpResponse(status=400)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event['type'] == 'checkout.session.completed'
|
event['type'] == 'checkout.session.completed'
|
||||||
or event['type'] == 'checkout.session.async_payment_succeeded'
|
or event['type'] == 'checkout.session.async_payment_succeeded'
|
||||||
):
|
):
|
||||||
fulfill_checkout(event['data']['object']['id'])
|
fulfill_checkout(event['data']['object']['id'])
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue