mirror of
https://github.com/pretix/pretix.git
synced 2026-08-23 12:51:59 +00:00
Connect giftcards with customer accounts (#5126)
Connect giftcards with customer accounts, show giftcards during checkout and in account , show giftcard list in backend customer view
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-05-19 11:06
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pretixbase', '0291_alter_logentry_object_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='giftcard',
|
||||||
|
name='customer',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customer_gift_cards', to='pretixbase.customer'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pycountry
|
import pycountry
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import (
|
from django.contrib.auth.hashers import (
|
||||||
@@ -27,7 +29,11 @@ from django.contrib.auth.hashers import (
|
|||||||
from django.core.validators import RegexValidator, URLValidator
|
from django.core.validators import RegexValidator, URLValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
from django.db.models.aggregates import Sum
|
||||||
|
from django.db.models.expressions import OuterRef, Subquery
|
||||||
|
from django.db.models.functions.comparison import Coalesce
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
from i18nfield.fields import I18nCharField
|
from i18nfield.fields import I18nCharField
|
||||||
@@ -36,6 +42,7 @@ from phonenumber_field.modelfields import PhoneNumberField
|
|||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.fields import MultiStringField
|
from pretix.base.models.fields import MultiStringField
|
||||||
|
from pretix.base.models.giftcards import GiftCardTransaction
|
||||||
from pretix.base.models.organizer import Organizer
|
from pretix.base.models.organizer import Organizer
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.helpers.countries import FastCountryField
|
from pretix.helpers.countries import FastCountryField
|
||||||
@@ -288,6 +295,19 @@ class Customer(LoggedModel):
|
|||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def usable_gift_cards(self, used_cards=[]):
|
||||||
|
s = GiftCardTransaction.objects.filter(
|
||||||
|
card=OuterRef('pk')
|
||||||
|
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||||
|
qs = self.customer_gift_cards.annotate(
|
||||||
|
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||||
|
)
|
||||||
|
ne_qs = qs.filter(
|
||||||
|
Q(expires__isnull=True) | Q(expires__gte=now()),
|
||||||
|
)
|
||||||
|
ex_qs = ne_qs.exclude(id__in=used_cards)
|
||||||
|
return ex_qs.filter(cached_value__gt=0)
|
||||||
|
|
||||||
|
|
||||||
class AttendeeProfile(models.Model):
|
class AttendeeProfile(models.Model):
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ class GiftCard(LoggedModel):
|
|||||||
null=True, blank=True,
|
null=True, blank=True,
|
||||||
verbose_name=_('Owned by ticket holder')
|
verbose_name=_('Owned by ticket holder')
|
||||||
)
|
)
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
'Customer',
|
||||||
|
related_name='customer_gift_cards',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True, blank=True,
|
||||||
|
verbose_name=_('Owned by customer account')
|
||||||
|
)
|
||||||
issuance = models.DateTimeField(
|
issuance = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from functools import cached_property
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@@ -57,8 +58,8 @@ from i18nfield.strings import LazyI18nString
|
|||||||
|
|
||||||
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
|
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
CartPosition, Customer, Event, GiftCard, InvoiceAddress, Order,
|
||||||
OrderRefund, Quota, TaxRule,
|
OrderPayment, OrderRefund, Quota, TaxRule,
|
||||||
)
|
)
|
||||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||||
from pretix.base.settings import SettingsSandbox
|
from pretix.base.settings import SettingsSandbox
|
||||||
@@ -99,6 +100,7 @@ class PaymentProviderForm(Form):
|
|||||||
|
|
||||||
class GiftCardPaymentForm(PaymentProviderForm):
|
class GiftCardPaymentForm(PaymentProviderForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.customer_gift_cards = kwargs.pop('customer_gift_cards') if 'customer_gift_cards' in kwargs else None
|
||||||
self.event = kwargs.pop('event')
|
self.event = kwargs.pop('event')
|
||||||
self.testmode = kwargs.pop('testmode')
|
self.testmode = kwargs.pop('testmode')
|
||||||
self.positions = kwargs.pop('positions')
|
self.positions = kwargs.pop('positions')
|
||||||
@@ -1373,6 +1375,31 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
payment_form_class = GiftCardPaymentForm
|
payment_form_class = GiftCardPaymentForm
|
||||||
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
|
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def customer_gift_cards(self):
|
||||||
|
if not self.request:
|
||||||
|
return None
|
||||||
|
if not self.used_cards:
|
||||||
|
self.used_cards = []
|
||||||
|
cs = None
|
||||||
|
if 'checkout' in self.request.resolver_match.url_name:
|
||||||
|
cs = cart_session(self.request)
|
||||||
|
customer = getattr(self.request, "customer", None)
|
||||||
|
if customer:
|
||||||
|
return customer.usable_gift_cards(self.used_cards)
|
||||||
|
elif cs and cs.get('customer_mode', 'guest') == 'login':
|
||||||
|
try:
|
||||||
|
customer = self.request.organizer.customers.get(pk=cs["customer"])
|
||||||
|
return customer.usable_gift_cards(self.used_cards)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order = None) -> str:
|
||||||
|
form = self.payment_form(request)
|
||||||
|
template = get_template(self.payment_form_template_name)
|
||||||
|
ctx = {'request': request, 'form': form, 'customer_gift_cards': form.customer_gift_cards, }
|
||||||
|
return template.render(ctx)
|
||||||
|
|
||||||
def payment_form(self, request: HttpRequest) -> Form:
|
def payment_form(self, request: HttpRequest) -> Form:
|
||||||
# Unfortunately, in payment_form we do not know if we're in checkout
|
# Unfortunately, in payment_form we do not know if we're in checkout
|
||||||
# or in an existing order. But we need to do the validation logic in the
|
# or in an existing order. But we need to do the validation logic in the
|
||||||
@@ -1392,8 +1419,12 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
positions = order.positions.all()
|
positions = order.positions.all()
|
||||||
testmode = order.testmode
|
testmode = order.testmode
|
||||||
|
|
||||||
|
self.request = request
|
||||||
|
self.used_cards = used_cards
|
||||||
|
|
||||||
form = self.payment_form_class(
|
form = self.payment_form_class(
|
||||||
event=self.event,
|
event=self.event,
|
||||||
|
customer_gift_cards=self.customer_gift_cards,
|
||||||
used_cards=used_cards,
|
used_cards=used_cards,
|
||||||
positions=positions,
|
positions=positions,
|
||||||
testmode=testmode,
|
testmode=testmode,
|
||||||
|
|||||||
@@ -3087,6 +3087,7 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
|
|||||||
expires=order.event.organizer.default_gift_card_expiry if giftcard_expires is _unset else giftcard_expires,
|
expires=order.event.organizer.default_gift_card_expiry if giftcard_expires is _unset else giftcard_expires,
|
||||||
conditions=giftcard_conditions,
|
conditions=giftcard_conditions,
|
||||||
currency=order.event.currency,
|
currency=order.event.currency,
|
||||||
|
customer=order.customer,
|
||||||
testmode=order.testmode
|
testmode=order.testmode
|
||||||
)
|
)
|
||||||
giftcard.log_action('pretix.giftcards.created', data={})
|
giftcard.log_action('pretix.giftcards.created', data={})
|
||||||
|
|||||||
@@ -731,6 +731,21 @@ class GiftCardCreateForm(forms.ModelForm):
|
|||||||
kwargs['initial'] = initial
|
kwargs['initial'] = initial
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.organizer.settings.customer_accounts:
|
||||||
|
self.fields['customer'].queryset = self.organizer.customers.all()
|
||||||
|
self.fields['customer'].widget = Select2(
|
||||||
|
attrs={
|
||||||
|
'data-model-select2': 'generic',
|
||||||
|
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||||
|
'organizer': self.organizer.slug,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||||
|
self.fields['customer'].required = False
|
||||||
|
else:
|
||||||
|
del self.fields['customer']
|
||||||
|
|
||||||
def clean_secret(self):
|
def clean_secret(self):
|
||||||
s = self.cleaned_data['secret']
|
s = self.cleaned_data['secret']
|
||||||
if GiftCard.objects.filter(
|
if GiftCard.objects.filter(
|
||||||
@@ -749,9 +764,10 @@ class GiftCardCreateForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = GiftCard
|
model = GiftCard
|
||||||
fields = ['secret', 'currency', 'testmode', 'expires', 'conditions']
|
fields = ['secret', 'currency', 'testmode', 'expires', 'conditions', 'customer']
|
||||||
field_classes = {
|
field_classes = {
|
||||||
'expires': SplitDateTimeField
|
'expires': SplitDateTimeField,
|
||||||
|
'customer': SafeModelChoiceField,
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'expires': SplitDateTimePickerWidget,
|
'expires': SplitDateTimePickerWidget,
|
||||||
@@ -762,10 +778,11 @@ class GiftCardCreateForm(forms.ModelForm):
|
|||||||
class GiftCardUpdateForm(forms.ModelForm):
|
class GiftCardUpdateForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = GiftCard
|
model = GiftCard
|
||||||
fields = ['expires', 'conditions', 'owner_ticket']
|
fields = ['expires', 'conditions', 'owner_ticket', 'customer']
|
||||||
field_classes = {
|
field_classes = {
|
||||||
'expires': SplitDateTimeField,
|
'expires': SplitDateTimeField,
|
||||||
'owner_ticket': SafeOrderPositionChoiceField,
|
'owner_ticket': SafeOrderPositionChoiceField,
|
||||||
|
'customer': SafeModelChoiceField,
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'expires': SplitDateTimePickerWidget,
|
'expires': SplitDateTimePickerWidget,
|
||||||
@@ -788,6 +805,21 @@ class GiftCardUpdateForm(forms.ModelForm):
|
|||||||
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
|
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
|
||||||
self.fields['owner_ticket'].required = False
|
self.fields['owner_ticket'].required = False
|
||||||
|
|
||||||
|
if organizer.settings.customer_accounts:
|
||||||
|
self.fields['customer'].queryset = organizer.customers.all()
|
||||||
|
self.fields['customer'].widget = Select2(
|
||||||
|
attrs={
|
||||||
|
'data-model-select2': 'generic',
|
||||||
|
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||||
|
'organizer': organizer.slug,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||||
|
self.fields['customer'].required = False
|
||||||
|
else:
|
||||||
|
del self.fields['customer']
|
||||||
|
|
||||||
|
|
||||||
class ReusableMediumUpdateForm(forms.ModelForm):
|
class ReusableMediumUpdateForm(forms.ModelForm):
|
||||||
error_messages = {
|
error_messages = {
|
||||||
|
|||||||
@@ -1,7 +1,35 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
|
{% load money %}
|
||||||
{% load rich_text %}
|
{% load rich_text %}
|
||||||
|
|
||||||
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
|
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
|
||||||
|
{% if customer_gift_cards %}
|
||||||
|
<p><strong>
|
||||||
|
<span class="sr-only">{% trans "Information" %}</span>
|
||||||
|
{% trans "The following gift cards are available in your customer account:" %}
|
||||||
|
</strong></p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for c in customer_gift_cards %}
|
||||||
|
<li class="list-group-item row row-no-gutters">
|
||||||
|
<div class="col-sm-8 col-md-9" id="gc-code-{{ forloop.counter }}">
|
||||||
|
{{ c }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-2 text-right" id="gc-value-{{ forloop.counter }}">
|
||||||
|
{{ c.value|money:c.currency }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-2 col-md-1 text-right">
|
||||||
|
<button name="use_giftcard" class="btn btn-primary btn-xs use_giftcard" data-value="{{ c.secret }}"
|
||||||
|
title="{% trans "Use gift card" %}"
|
||||||
|
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}">
|
||||||
|
{% trans "Apply" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% bootstrap_form form layout='checkout' %}
|
{% bootstrap_form form layout='checkout' %}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "pretixcontrol/organizers/base.html" %}
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
|
{% load icon %}
|
||||||
{% load money %}
|
{% load money %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@@ -278,6 +279,53 @@
|
|||||||
</table>
|
</table>
|
||||||
{% include "pretixcontrol/pagination.html" %}
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel panel-default items">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% trans "Gift cards" %}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<table class="panel-body table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Gift card code" %}
|
||||||
|
<th>{% trans "Creation date" %}
|
||||||
|
<th>{% trans "Expiry date" %}
|
||||||
|
<th>{% trans "Last transaction" %}
|
||||||
|
<th class="text-right">{% trans "Current value" %}
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for gc in gift_cards %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "control:organizer.giftcard" organizer=organizer.slug giftcard=gc.id %}">
|
||||||
|
<strong>{{ gc.secret }}</strong></a>
|
||||||
|
{% if gc.testmode %}
|
||||||
|
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if gc.expired %}
|
||||||
|
<span class="label label-danger">{% trans "Expired" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ gc.issuance|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||||
|
<td>{% if gc.expires %}{{ gc.expires|date:"SHORT_DATETIME_FORMAT" }}{% endif %}</td>
|
||||||
|
<td>{% if gc.last_tx %}{{ gc.last_tx|date:"SHORT_DATETIME_FORMAT" }}{% endif %}</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<p class="text-right">{{ gc.value|money:gc.currency }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="{% url "control:organizer.giftcard" organizer=organizer.slug giftcard=gc.id %}"
|
||||||
|
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 col-xs-12">
|
<div class="col-md-2 col-xs-12">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
|||||||
@@ -54,6 +54,14 @@
|
|||||||
{{ card.owner_ticket.order.code }}</a>-{{ card.owner_ticket.positionid }}
|
{{ card.owner_ticket.order.code }}</a>-{{ card.owner_ticket.positionid }}
|
||||||
</dd>
|
</dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if card.customer %}
|
||||||
|
<dt>{% trans "Customer account" %}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=card.customer.identifier %}">
|
||||||
|
{{ card.customer.identifier }} – {{ card.customer.email }}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
{% bootstrap_field form.testmode layout="control" %}
|
{% bootstrap_field form.testmode layout="control" %}
|
||||||
{% bootstrap_field form.expires layout="control" %}
|
{% bootstrap_field form.expires layout="control" %}
|
||||||
{% bootstrap_field form.conditions layout="control" %}
|
{% bootstrap_field form.conditions layout="control" %}
|
||||||
|
{% if form.customer %}
|
||||||
|
{% bootstrap_field form.customer layout="control" %}
|
||||||
|
{% endif %}
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
{% bootstrap_field form.expires layout="control" %}
|
{% bootstrap_field form.expires layout="control" %}
|
||||||
{% bootstrap_field form.owner_ticket layout="control" %}
|
{% bootstrap_field form.owner_ticket layout="control" %}
|
||||||
{% bootstrap_field form.conditions layout="control" %}
|
{% bootstrap_field form.conditions layout="control" %}
|
||||||
|
{% if form.customer %}
|
||||||
|
{% bootstrap_field form.customer layout="control" %}
|
||||||
|
{% endif %}
|
||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
|
|||||||
@@ -1221,6 +1221,7 @@ class OrderRefundView(OrderView):
|
|||||||
giftcard = self.request.organizer.issued_gift_cards.create(
|
giftcard = self.request.organizer.issued_gift_cards.create(
|
||||||
expires=expires,
|
expires=expires,
|
||||||
currency=self.request.event.currency,
|
currency=self.request.event.currency,
|
||||||
|
customer=order.customer,
|
||||||
testmode=order.testmode
|
testmode=order.testmode
|
||||||
)
|
)
|
||||||
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={})
|
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={})
|
||||||
|
|||||||
@@ -3085,6 +3085,8 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
.annotate(spending=Sum("total"))
|
.annotate(spending=Sum("total"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ctx["gift_cards"] = self.customer.customer_gift_cards.all()
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,34 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
<h3 class="sr-only">{% trans "Payment" %}</h3>
|
<h3 class="sr-only">{% trans "Payment" %}</h3>
|
||||||
|
{% if customer_gift_cards %}
|
||||||
|
<p><strong>
|
||||||
|
<span class="sr-only">{% trans "Information" %}</span>
|
||||||
|
{% trans "The following gift cards are available in your customer account:" %}
|
||||||
|
</strong></p>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for c in customer_gift_cards %}
|
||||||
|
<li class="list-group-item row">
|
||||||
|
<div class="col-xs-9" id="gc-code-{{ forloop.counter }}">
|
||||||
|
{{ c }}
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-2 text-right" id="gc-value-{{ forloop.counter }}">
|
||||||
|
{{ c.value|money:c.currency }}
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-1 text-right">
|
||||||
|
<button name="use_giftcard" value="{{ c.secret }}" title="{% trans "Use gift card" %}"
|
||||||
|
aria-describedby="gc-code-{{ forloop.counter }} gc-value-{{ forloop.counter }}"
|
||||||
|
class="btn btn-primary btn-xs">
|
||||||
|
{% trans "Apply" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% if current_payments %}
|
{% if current_payments %}
|
||||||
<p>{% trans "You already selected the following payment methods:" %}</p>
|
<p>{% trans "You already selected the following payment methods:" %}</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -77,6 +105,15 @@
|
|||||||
aria-controls="payment_{{ p.provider.identifier }}"
|
aria-controls="payment_{{ p.provider.identifier }}"
|
||||||
data-wallets="{{ p.provider.walletqueries|join:"|" }}" />
|
data-wallets="{{ p.provider.walletqueries|join:"|" }}" />
|
||||||
<strong class="accordion-label-text">{{ p.provider.public_name }}</strong>
|
<strong class="accordion-label-text">{{ p.provider.public_name }}</strong>
|
||||||
|
{% if p.provider.identifier == 'giftcard' and p.provider.customer_gift_cards %}
|
||||||
|
<small>
|
||||||
|
{% blocktrans trimmed count count=p.provider.customer_gift_cards|length %}
|
||||||
|
({{ count }} available)
|
||||||
|
{% plural %}
|
||||||
|
({{ count }} available)
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</legend>
|
</legend>
|
||||||
|
|||||||
@@ -40,6 +40,15 @@
|
|||||||
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
|
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
|
||||||
data-wallets="{{ p.provider.walletqueries|join:"|" }}"/>
|
data-wallets="{{ p.provider.walletqueries|join:"|" }}"/>
|
||||||
<strong class="accordion-label-text">{{ p.provider.public_name }}</strong>
|
<strong class="accordion-label-text">{{ p.provider.public_name }}</strong>
|
||||||
|
{% if p.provider.identifier == 'giftcard' and p.provider.customer_gift_cards %}
|
||||||
|
<small>
|
||||||
|
{% blocktrans trimmed count count=p.provider.customer_gift_cards|length %}
|
||||||
|
({{ count }} available)
|
||||||
|
{% plural %}
|
||||||
|
({{ count }} available)
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</legend>
|
</legend>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "pretixpresale/organizers/customer_base.html" %}
|
||||||
|
{% load money %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load icon %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% load textbubble %}
|
||||||
|
{% block title %}{% trans "Gift cards" %}{% endblock %}
|
||||||
|
{% block inner %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
{% icon "gift" %}
|
||||||
|
<strong>{% trans "Gift cards" %}</strong> ({{ page_obj.paginator.count }})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% if gift_cards %}
|
||||||
|
<div class="event-list full-width-list alternating-rows">
|
||||||
|
{% for gc in gift_cards %}
|
||||||
|
<article class="row">
|
||||||
|
<div class="col-xs-6">
|
||||||
|
<h4>
|
||||||
|
{% icon "gift" %} {{ gc }}
|
||||||
|
</h4>
|
||||||
|
{% if gc.issuance %}
|
||||||
|
<p class="text-muted">
|
||||||
|
{% icon "calendar" %}
|
||||||
|
{% blocktrans trimmed with date=gc.issuance|date:"SHORT_DATE_FORMAT" %}
|
||||||
|
Issued on {{ date }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-muted">
|
||||||
|
<small>
|
||||||
|
{% if gc.expired %}
|
||||||
|
{% icon "clock-o" %}
|
||||||
|
{% if gc.expires %}
|
||||||
|
{% blocktrans trimmed with date=gc.expires|date:"SHORT_DATETIME_FORMAT" %}
|
||||||
|
Expired since {{ date }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Expired" %}
|
||||||
|
{% endif %}
|
||||||
|
{% elif gc.expires %}
|
||||||
|
{% icon "check" %}
|
||||||
|
{% blocktrans trimmed with date=gc.expires|date:"SHORT_DATETIME_FORMAT" %}
|
||||||
|
Valid until {{ date }}
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% icon "check" %}
|
||||||
|
{% trans "Valid" %}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
{% if gc.testmode %}
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
{% textbubble "warning" %}
|
||||||
|
{% trans "TEST MODE" %}
|
||||||
|
{% endtextbubble %}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-6 text-right">
|
||||||
|
{% blocktrans trimmed with value=gc.value|money:gc.currency %}
|
||||||
|
Remaining value:
|
||||||
|
{% endblocktrans %}
|
||||||
|
<p class="text-right">{{ value }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center">
|
||||||
|
{% trans "You don’t have any gift cards in your account currently." %}
|
||||||
|
{% trans "Currently, only gift cards resulting from refunds show up here, any purchased gift cards show up under the orders tab." %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
{% endblock %}
|
||||||
@@ -214,6 +214,8 @@ organizer_patterns = [
|
|||||||
re_path(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'),
|
re_path(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'),
|
||||||
re_path(r'^account/memberships$', pretix.presale.views.customer.MembershipView.as_view(), name='organizer.customer.memberships'),
|
re_path(r'^account/memberships$', pretix.presale.views.customer.MembershipView.as_view(), name='organizer.customer.memberships'),
|
||||||
re_path(r'^account/memberships/(?P<id>\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'),
|
re_path(r'^account/memberships/(?P<id>\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'),
|
||||||
|
re_path(r'^account/giftcards$', pretix.presale.views.customer.GiftcardView.as_view(),
|
||||||
|
name='organizer.customer.giftcards'),
|
||||||
re_path(r'^account/addresses$', pretix.presale.views.customer.AddressView.as_view(), name='organizer.customer.addresses'),
|
re_path(r'^account/addresses$', pretix.presale.views.customer.AddressView.as_view(), name='organizer.customer.addresses'),
|
||||||
re_path(r'^account/addresses/(?P<id>\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
|
re_path(r'^account/addresses/(?P<id>\d+)/delete$', pretix.presale.views.customer.AddressDeleteView.as_view(), name='organizer.customer.address.delete'),
|
||||||
re_path(r'^account/profiles$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profiles'),
|
re_path(r'^account/profiles$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profiles'),
|
||||||
|
|||||||
@@ -368,6 +368,12 @@ class CustomerAccountBaseMixin(CustomerRequiredMixin):
|
|||||||
'active': url_name.startswith('organizer.customer.membership'),
|
'active': url_name.startswith('organizer.customer.membership'),
|
||||||
'icon': 'id-badge',
|
'icon': 'id-badge',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'label': _('Gift cards'),
|
||||||
|
'url': eventreverse(self.request.organizer, 'presale:organizer.customer.giftcards', kwargs={}),
|
||||||
|
'active': url_name.startswith('organizer.customer.giftcard'),
|
||||||
|
'icon': 'gift',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'label': _('Addresses'),
|
'label': _('Addresses'),
|
||||||
'url': eventreverse(self.request.organizer, 'presale:organizer.customer.addresses', kwargs={}),
|
'url': eventreverse(self.request.organizer, 'presale:organizer.customer.addresses', kwargs={}),
|
||||||
@@ -461,6 +467,15 @@ class MembershipUsageView(CustomerAccountBaseMixin, ListView):
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class GiftcardView(CustomerAccountBaseMixin, ListView):
|
||||||
|
template_name = 'pretixpresale/organizers/customer_giftcards.html'
|
||||||
|
context_object_name = 'gift_cards'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.request.customer.customer_gift_cards.all()
|
||||||
|
|
||||||
|
|
||||||
class AddressView(CustomerAccountBaseMixin, ListView):
|
class AddressView(CustomerAccountBaseMixin, ListView):
|
||||||
template_name = 'pretixpresale/organizers/customer_addresses.html'
|
template_name = 'pretixpresale/organizers/customer_addresses.html'
|
||||||
context_object_name = 'invoice_addresses'
|
context_object_name = 'invoice_addresses'
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ var form_handlers = function (el) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
el.find('.use_giftcard').on("click", function () {
|
||||||
|
var value = $(this).data('value');
|
||||||
|
$('#id_payment_giftcard-code').val(value)
|
||||||
|
})
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function setup_basics(el) {
|
function setup_basics(el) {
|
||||||
|
|||||||
@@ -5488,3 +5488,22 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
|
|||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert order.positions.first().used_membership == m_correct1
|
assert order.positions.first().used_membership == m_correct1
|
||||||
assert order.positions.first().attendee_name == 'John Doe'
|
assert order.positions.first().attendee_name == 'John Doe'
|
||||||
|
|
||||||
|
def test_giftcard_customer_offered(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
gc = self.orga.issued_gift_cards.create(currency="EUR", customer=self.customer)
|
||||||
|
gc.transactions.create(value=23, acceptor=self.orga)
|
||||||
|
ugc = self.customer.usable_gift_cards()
|
||||||
|
assert len(ugc) == 1
|
||||||
|
CartPosition.objects.create(
|
||||||
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
|
price=23, expires=now() + timedelta(minutes=10)
|
||||||
|
)
|
||||||
|
self.client.post('/%s/%s/checkout/customer/' % (self.orga.slug, self.event.slug), {
|
||||||
|
'customer_mode': 'login',
|
||||||
|
'login-email': 'john@example.org',
|
||||||
|
'login-password': 'foo',
|
||||||
|
}, follow=True)
|
||||||
|
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
|
assert 'Gift card' in response.content.decode()
|
||||||
|
assert '(1 available)' in response.content.decode()
|
||||||
|
|||||||
@@ -1915,6 +1915,47 @@ class OrderChangeAddonsTest(BaseOrdersTest):
|
|||||||
r = self.order.refunds.get()
|
r = self.order.refunds.get()
|
||||||
assert r.provider == 'giftcard'
|
assert r.provider == 'giftcard'
|
||||||
|
|
||||||
|
def test_refund_giftcard_to_customer_account(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
|
||||||
|
customer.set_password('foo')
|
||||||
|
customer.save()
|
||||||
|
|
||||||
|
self.order.customer = customer
|
||||||
|
self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'force'
|
||||||
|
with scopes_disabled():
|
||||||
|
gc = customer.usable_gift_cards()
|
||||||
|
assert len(gc) == 0
|
||||||
|
OrderPosition.objects.create(
|
||||||
|
order=self.order,
|
||||||
|
item=self.workshop1,
|
||||||
|
variation=None,
|
||||||
|
price=Decimal("12"),
|
||||||
|
addon_to=self.ticket_pos,
|
||||||
|
attendee_name_parts={'full_name': "Peter"},
|
||||||
|
)
|
||||||
|
self.order.status = Order.STATUS_PAID
|
||||||
|
self.order.total += Decimal("12")
|
||||||
|
self.order.save()
|
||||||
|
self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total,
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
'/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {},
|
||||||
|
follow=True
|
||||||
|
)
|
||||||
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
|
form_data = extract_form_fields(doc.select('.main-box form')[0])
|
||||||
|
form_data['confirm'] = 'true'
|
||||||
|
self.client.post(
|
||||||
|
'/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||||
|
form_data, follow=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with scopes_disabled():
|
||||||
|
gc = customer.usable_gift_cards()
|
||||||
|
assert len(gc) == 1
|
||||||
|
|
||||||
def test_attendee(self):
|
def test_attendee(self):
|
||||||
self.workshop2a.default_price = Decimal('0.00')
|
self.workshop2a.default_price = Decimal('0.00')
|
||||||
self.workshop2a.save()
|
self.workshop2a.save()
|
||||||
|
|||||||
@@ -702,6 +702,46 @@ class OrdersTest(BaseOrdersTest):
|
|||||||
assert r.provider == "giftcard"
|
assert r.provider == "giftcard"
|
||||||
assert r.amount == Decimal('20.00')
|
assert r.amount == Decimal('20.00')
|
||||||
|
|
||||||
|
def test_orders_cancel_autorefund_gift_card_customer(self):
|
||||||
|
self.order.status = Order.STATUS_PAID
|
||||||
|
self.order.save()
|
||||||
|
with scopes_disabled():
|
||||||
|
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
|
||||||
|
customer.set_password('foo')
|
||||||
|
customer.save()
|
||||||
|
self.order.customer = customer
|
||||||
|
self.order.save()
|
||||||
|
with scopes_disabled():
|
||||||
|
self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total,
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||||
|
self.event.settings.cancel_allow_user_paid = True
|
||||||
|
self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'option'
|
||||||
|
gc = customer.usable_gift_cards()
|
||||||
|
assert len(gc) == 0
|
||||||
|
response = self.client.get(
|
||||||
|
'/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'manually' not in response.content.decode()
|
||||||
|
assert "gift card" in response.content.decode()
|
||||||
|
response = self.client.post(
|
||||||
|
'/%s/%s/order/%s/%s/cancel/do' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
|
||||||
|
'giftcard': 'true'
|
||||||
|
}, follow=True)
|
||||||
|
self.assertRedirects(response,
|
||||||
|
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
|
||||||
|
self.order.secret),
|
||||||
|
target_status_code=200)
|
||||||
|
assert "gift card" in response.content.decode()
|
||||||
|
self.order.refresh_from_db()
|
||||||
|
assert self.order.status == Order.STATUS_CANCELED
|
||||||
|
with scopes_disabled():
|
||||||
|
r = self.order.refunds.get()
|
||||||
|
assert r.provider == "giftcard"
|
||||||
|
assert r.amount == Decimal('23.00')
|
||||||
|
gc = customer.usable_gift_cards()
|
||||||
|
assert len(gc) == 1
|
||||||
|
|
||||||
def test_orders_cancel_unpaid_fee(self):
|
def test_orders_cancel_unpaid_fee(self):
|
||||||
self.order.status = Order.STATUS_PENDING
|
self.order.status = Order.STATUS_PENDING
|
||||||
self.order.save()
|
self.order.save()
|
||||||
@@ -1583,6 +1623,35 @@ class OrdersTest(BaseOrdersTest):
|
|||||||
assert self.order.status == Order.STATUS_PAID
|
assert self.order.status == Order.STATUS_PAID
|
||||||
assert gc.value == Decimal('87.00')
|
assert gc.value == Decimal('87.00')
|
||||||
|
|
||||||
|
def test_change_paymentmethod_customeraccount_giftcard_offered(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.orga.settings.customer_accounts = True
|
||||||
|
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
|
||||||
|
customer.set_password('foo')
|
||||||
|
customer.save()
|
||||||
|
self.order.customer = customer
|
||||||
|
self.order.payments.create(
|
||||||
|
provider='manual',
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||||
|
amount=Decimal('10.00'),
|
||||||
|
)
|
||||||
|
gc = self.orga.issued_gift_cards.create(currency="EUR", customer=customer)
|
||||||
|
gc.transactions.create(value=100, acceptor=self.orga)
|
||||||
|
ugc = customer.usable_gift_cards()
|
||||||
|
assert len(ugc) == 1
|
||||||
|
r = self.client.post('/%s/account/login' % (self.orga.slug), {
|
||||||
|
'email': 'john@example.org',
|
||||||
|
'password': 'foo',
|
||||||
|
})
|
||||||
|
assert r.status_code == 302
|
||||||
|
r = self.client.get('/%s/account/' % (self.orga.slug))
|
||||||
|
assert r.status_code == 200
|
||||||
|
response = self.client.get(
|
||||||
|
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||||
|
)
|
||||||
|
assert 'Gift card' in response.content.decode()
|
||||||
|
assert '1 available' in response.content.decode()
|
||||||
|
|
||||||
def test_answer_download_token(self):
|
def test_answer_download_token(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
q = self.event.questions.create(question="Foo", type="F")
|
q = self.event.questions.create(question="Foo", type="F")
|
||||||
|
|||||||
Reference in New Issue
Block a user