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:
Phin Wolkwitz
2025-10-16 13:20:00 +02:00
committed by GitHub
parent 71f2c8093f
commit 8a3da37b45
22 changed files with 490 additions and 6 deletions
@@ -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'),
),
]
+20
View File
@@ -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(
+7
View File
@@ -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,
) )
+33 -2
View File
@@ -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,
+1
View File
@@ -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={})
+35 -3
View File
@@ -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" %}
+1
View File
@@ -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={})
+2
View File
@@ -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 dont 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 %}
+2
View File
@@ -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'),
+15
View File
@@ -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) {
+19
View File
@@ -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()
+41
View File
@@ -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()
+69
View File
@@ -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")