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

View File

@@ -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'),
),
]

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
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
import pycountry
from django.conf import settings
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.db import models
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.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField
@@ -36,6 +42,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.giftcards import GiftCardTransaction
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
@@ -288,6 +295,19 @@ class Customer(LoggedModel):
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):
customer = models.ForeignKey(

View File

@@ -80,6 +80,13 @@ class GiftCard(LoggedModel):
null=True, blank=True,
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(
auto_now_add=True,
)

View File

@@ -38,6 +38,7 @@ import json
import logging
from collections import OrderedDict
from decimal import ROUND_HALF_UP, Decimal
from functools import cached_property
from typing import Any, Dict, Union
from zoneinfo import ZoneInfo
@@ -57,8 +58,8 @@ from i18nfield.strings import LazyI18nString
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
OrderRefund, Quota, TaxRule,
CartPosition, Customer, Event, GiftCard, InvoiceAddress, Order,
OrderPayment, OrderRefund, Quota, TaxRule,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
@@ -99,6 +100,7 @@ class PaymentProviderForm(Form):
class GiftCardPaymentForm(PaymentProviderForm):
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.testmode = kwargs.pop('testmode')
self.positions = kwargs.pop('positions')
@@ -1373,6 +1375,31 @@ class GiftCardPayment(BasePaymentProvider):
payment_form_class = GiftCardPaymentForm
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:
# 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
@@ -1392,8 +1419,12 @@ class GiftCardPayment(BasePaymentProvider):
positions = order.positions.all()
testmode = order.testmode
self.request = request
self.used_cards = used_cards
form = self.payment_form_class(
event=self.event,
customer_gift_cards=self.customer_gift_cards,
used_cards=used_cards,
positions=positions,
testmode=testmode,

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,
conditions=giftcard_conditions,
currency=order.event.currency,
customer=order.customer,
testmode=order.testmode
)
giftcard.log_action('pretix.giftcards.created', data={})

View File

@@ -731,6 +731,21 @@ class GiftCardCreateForm(forms.ModelForm):
kwargs['initial'] = initial
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):
s = self.cleaned_data['secret']
if GiftCard.objects.filter(
@@ -749,9 +764,10 @@ class GiftCardCreateForm(forms.ModelForm):
class Meta:
model = GiftCard
fields = ['secret', 'currency', 'testmode', 'expires', 'conditions']
fields = ['secret', 'currency', 'testmode', 'expires', 'conditions', 'customer']
field_classes = {
'expires': SplitDateTimeField
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
@@ -762,10 +778,11 @@ class GiftCardCreateForm(forms.ModelForm):
class GiftCardUpdateForm(forms.ModelForm):
class Meta:
model = GiftCard
fields = ['expires', 'conditions', 'owner_ticket']
fields = ['expires', 'conditions', 'owner_ticket', 'customer']
field_classes = {
'expires': SplitDateTimeField,
'owner_ticket': SafeOrderPositionChoiceField,
'customer': SafeModelChoiceField,
}
widgets = {
'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'].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):
error_messages = {

View File

@@ -1,7 +1,35 @@
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% load 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' %}

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% load money %}
{% load static %}
{% block title %}
@@ -278,6 +279,53 @@
</table>
{% include "pretixcontrol/pagination.html" %}
</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 class="col-md-2 col-xs-12">
<div class="panel panel-default">

View File

@@ -54,6 +54,14 @@
{{ card.owner_ticket.order.code }}</a>-{{ card.owner_ticket.positionid }}
</dd>
{% 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>
</div>
</div>

View File

@@ -12,6 +12,9 @@
{% bootstrap_field form.testmode layout="control" %}
{% bootstrap_field form.expires layout="control" %}
{% bootstrap_field form.conditions layout="control" %}
{% if form.customer %}
{% bootstrap_field form.customer layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -16,6 +16,9 @@
{% bootstrap_field form.expires layout="control" %}
{% bootstrap_field form.owner_ticket layout="control" %}
{% bootstrap_field form.conditions layout="control" %}
{% if form.customer %}
{% bootstrap_field form.customer layout="control" %}
{% endif %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -1221,6 +1221,7 @@ class OrderRefundView(OrderView):
giftcard = self.request.organizer.issued_gift_cards.create(
expires=expires,
currency=self.request.event.currency,
customer=order.customer,
testmode=order.testmode
)
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={})

View File

@@ -3085,6 +3085,8 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
.annotate(spending=Sum("total"))
)
ctx["gift_cards"] = self.customer.customer_gift_cards.all()
return ctx

View File

@@ -9,6 +9,34 @@
{% endblock %}
{% block inner %}
<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 %}
<p>{% trans "You already selected the following payment methods:" %}</p>
<form method="post">
@@ -77,6 +105,15 @@
aria-controls="payment_{{ p.provider.identifier }}"
data-wallets="{{ p.provider.walletqueries|join:"|" }}" />
<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>
</label>
</legend>

View File

@@ -40,6 +40,15 @@
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
data-wallets="{{ p.provider.walletqueries|join:"|" }}"/>
<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>
</label>
</legend>

View File

@@ -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 %}

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/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/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/(?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'),

View File

@@ -368,6 +368,12 @@ class CustomerAccountBaseMixin(CustomerRequiredMixin):
'active': url_name.startswith('organizer.customer.membership'),
'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'),
'url': eventreverse(self.request.organizer, 'presale:organizer.customer.addresses', kwargs={}),
@@ -461,6 +467,15 @@ class MembershipUsageView(CustomerAccountBaseMixin, ListView):
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):
template_name = 'pretixpresale/organizers/customer_addresses.html'
context_object_name = 'invoice_addresses'

View File

@@ -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) {