diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst index 003b127d2a..ba9d53ade1 100644 --- a/doc/api/resources/giftcards.rst +++ b/doc/api/resources/giftcards.rst @@ -18,6 +18,8 @@ secret string Gift card code value money (string) Current gift card value currency string Currency of the value (can not be modified later) testmode boolean Whether this is a test gift card +expires datetime Expiry date (or ``null``) +conditions string Special terms and conditions for this card (or ``null``) ===================================== ========================== ======================================================= Endpoints @@ -53,6 +55,8 @@ Endpoints "secret": "HLBYVELFRC77NCQY", "currency": "EUR", "testmode": false, + "expires": null, + "conditions": null, "value": "13.37" } ] @@ -92,6 +96,8 @@ Endpoints "secret": "HLBYVELFRC77NCQY", "currency": "EUR", "testmode": false, + "expires": null, + "conditions": null, "value": "13.37" } @@ -134,6 +140,8 @@ Endpoints "secret": "HLBYVELFRC77NCQY", "testmode": false, "currency": "EUR", + "expires": null, + "conditions": null, "value": "13.37" } @@ -180,6 +188,8 @@ Endpoints "secret": "HLBYVELFRC77NCQY", "testmode": false, "currency": "EUR", + "expires": null, + "conditions": null, "value": "14.00" } @@ -222,6 +232,8 @@ Endpoints "secret": "HLBYVELFRC77NCQY", "currency": "EUR", "testmode": false, + "expires": null, + "conditions": null, "value": "15.37" } diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 5d9ec912b3..5c02491e94 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -55,7 +55,7 @@ class GiftCardSerializer(I18nAwareModelSerializer): class Meta: model = GiftCard - fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode') + fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions') class EventSlugField(serializers.SlugRelatedField): diff --git a/src/pretix/base/migrations/0151_auto_20200421_0737.py b/src/pretix/base/migrations/0151_auto_20200421_0737.py new file mode 100644 index 0000000000..8744f2d1c4 --- /dev/null +++ b/src/pretix/base/migrations/0151_auto_20200421_0737.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.5 on 2020-04-21 07:37 + +import django_countries.fields +from django.db import migrations, models + +import pretix.helpers.countries + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0150_auto_20200401_1123'), + ] + + operations = [ + migrations.AddField( + model_name='giftcard', + name='conditions', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='giftcard', + name='expires', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index 625c79e823..4162eb309e 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -5,7 +5,8 @@ from django.core.validators import RegexValidator from django.db import models from django.db.models import Sum from django.utils.crypto import get_random_string -from django.utils.translation import gettext_lazy as _ +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _, pgettext_lazy from pretix.base.banlist import banned from pretix.base.models import LoggedModel @@ -62,12 +63,22 @@ class GiftCard(LoggedModel): verbose_name=_('Test mode card'), default=False ) + expires = models.DateTimeField( + null=True, blank=True, verbose_name=_('Expiry date') + ) + conditions = models.TextField( + null=True, blank=True, verbose_name=pgettext_lazy('giftcard', 'Special terms and conditions') + ) CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES] currency = models.CharField(max_length=10, choices=CURRENCY_CHOICES) def __str__(self): return self.secret + @property + def expired(self): + return self.expires and now() > self.expires + @property def value(self): return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00') diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 8195a48117..bae87c98fc 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -1,10 +1,12 @@ import string +from datetime import date, datetime, time from django.core.validators import RegexValidator from django.db import models from django.db.models import Exists, OuterRef, Q from django.utils.crypto import get_random_string from django.utils.functional import cached_property +from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.translation import gettext_lazy as _ from pretix.base.models.base import LoggedModel @@ -101,6 +103,15 @@ class Organizer(LoggedModel): Q(issuer=self) | Q(accepted=True) ) + @property + def default_gift_card_expiry(self): + if self.settings.giftcard_expiry_years is not None: + tz = get_current_timezone() + return make_aware(datetime.combine( + date(now().astimezone(tz).year + self.settings.get('giftcard_expiry_years', as_type=int), 12, 31), + time(hour=23, minute=59, second=59) + ), tz) + def allow_delete(self): from . import Order, Invoice return ( diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index c19679710e..54fedec84f 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1097,6 +1097,9 @@ class GiftCardPayment(BasePaymentProvider): if not gc.testmode and self.event.testmode: messages.error(request, _("Only test gift cards can be used in test mode.")) return + if gc.expires and gc.expires < now(): + messages.error(request, _("This gift card is no longer valid.")) + return if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) return @@ -1156,6 +1159,9 @@ class GiftCardPayment(BasePaymentProvider): if not gc.testmode and payment.order.testmode: messages.error(request, _("Only test gift cards can be used in test mode.")) return + if gc.expires and gc.expires < now(): + messages.error(request, _("This gift card is no longer valid.")) + return if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) return @@ -1194,6 +1200,9 @@ class GiftCardPayment(BasePaymentProvider): raise PaymentException(_("This gift card is not accepted by this event organizer.")) if payment.amount > gc.value: # noqa - just a safeguard raise PaymentException(_("This gift card was used in the meantime. Please try again")) + if gc.expires and gc.expires < now(): # noqa - just a safeguard + messages.error(request, _("This gift card is no longer valid.")) + return trans = gc.transactions.create( value=-1 * payment.amount, order=payment.order, diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index b351e53aba..a3e50c9760 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -86,7 +86,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_ keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None, send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={}, - user: int=None, refund_as_giftcard: bool=False): + user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None): send_subject = LazyI18nString(send_subject) send_message = LazyI18nString(send_message) send_waitinglist_subject = LazyI18nString(send_waitinglist_subject) @@ -169,7 +169,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_ try: if auto_refund: _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, - source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard) + source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard, + giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions) finally: if send: _send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all()) @@ -213,7 +214,9 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_ refund_amount = o.payment_refund_sum - o.total if auto_refund: - _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, source=OrderRefund.REFUND_SOURCE_ADMIN) + _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, + source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard, + giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions) if send: _send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 11aeb4f328..00360f6bb0 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1913,8 +1913,11 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str raise OrderError(str(error_messages['busy'])) +_unset = object() + + def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER, - refund_as_giftcard=False): + refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None): notify_admin = False error = False if isinstance(order, int): @@ -1929,6 +1932,8 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord can_auto_refund_sum = refund_amount with transaction.atomic(): giftcard = order.event.organizer.issued_gift_cards.create( + expires=order.event.organizer.default_gift_card_expiry if giftcard_expires is _unset else giftcard_expires, + conditions=giftcard_conditions, currency=order.event.currency, testmode=order.testmode ) @@ -2144,7 +2149,8 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): issued += gc.transactions.first().value if p.price - issued > 0: gc = sender.organizer.issued_gift_cards.create( - currency=sender.currency, issued_in=p, testmode=order.testmode + currency=sender.currency, issued_in=p, testmode=order.testmode, + expires=sender.organizer.default_gift_card_expiry, ) gc.transactions.create(value=p.price - issued, order=order) any_giftcards = True diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index f6d9146f11..be3a644dbb 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -10,7 +10,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.timezone import get_current_timezone_name -from django.utils.translation import gettext_lazy as _, pgettext_lazy +from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django_countries import Countries from django_countries.fields import LazyTypedChoiceField from i18nfield.forms import ( @@ -577,6 +577,13 @@ class CancelSettingsForm(SettingsForm): 'cancel_allow_user_paid_require_approval', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.obj.settings.giftcard_expiry_years is not None: + self.fields['cancel_allow_user_paid_refund_as_giftcard'].help_text = gettext( + 'You have configured gift cards to be valid {} years plus the year the gift card is issued in.' + ).format(self.obj.settings.giftcard_expiry_years) + class PaymentSettingsForm(SettingsForm): auto_fields = [ diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 3af060b3b2..158fbafc9e 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -78,7 +78,7 @@ class FilterForm(forms.Form): def get_order_by(self): o = self.cleaned_data.get('ordering') - if o.startswith('-'): + if o.startswith('-') and o not in self.orders: return '-' + self.orders[o[1:]] else: return self.orders[o] @@ -530,6 +530,33 @@ class OrganizerFilterForm(FilterForm): class GiftCardFilterForm(FilterForm): + orders = { + 'issuance': 'issuance', + 'expires': F('expires').asc(nulls_last=True), + '-expires': F('expires').desc(nulls_first=True), + 'secret': 'secret', + 'value': 'cached_value', + } + testmode = forms.ChoiceField( + label=_('Test mode'), + choices=( + ('', _('All')), + ('yes', _('Test mode')), + ('no', _('Live')), + ), + required=False + ) + state = forms.ChoiceField( + label=_('Empty'), + choices=( + ('', _('All')), + ('empty', _('Empty')), + ('valid_value', _('Valid and with value')), + ('expired_value', _('Expired and with value')), + ('expired', _('Expired')), + ), + required=False + ) query = forms.CharField( label=_('Search query'), widget=forms.TextInput(attrs={ @@ -548,8 +575,30 @@ class GiftCardFilterForm(FilterForm): if fdata.get('query'): query = fdata.get('query') - qs = qs.filter(secret__icontains=query) - return qs + qs = qs.filter( + Q(secret__icontains=query) + | Q(transactions__text__icontains=query) + | Q(transactions__order__code__icontains=query) + ) + if fdata.get('testmode') == 'yes': + qs = qs.filter(testmode=True) + elif fdata.get('testmode') == 'no': + qs = qs.filter(testmode=False) + if fdata.get('state') == 'empty': + qs = qs.filter(cached_value=0) + elif fdata.get('state') == 'valid_value': + qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=now())) + elif fdata.get('state') == 'expired_value': + qs = qs.exclude(cached_value=0).filter(expires__lt=now()) + elif fdata.get('state') == 'expired': + qs = qs.filter(expires__lt=now()) + + if fdata.get('ordering'): + qs = qs.order_by(self.get_order_by()) + else: + qs = qs.order_by('-issuance') + + return qs.distinct() class EventFilterForm(FilterForm): diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 9b5a205701..a9abb194b8 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -15,12 +15,15 @@ from i18nfield.strings import LazyI18nString from pretix.base.email import get_available_placeholders from pretix.base.forms import I18nModelForm, PlaceholderValidator -from pretix.base.forms.widgets import DatePickerWidget +from pretix.base.forms.widgets import ( + DatePickerWidget, SplitDateTimePickerWidget, +) from pretix.base.models import ( InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition, ) from pretix.base.models.event import SubEvent from pretix.base.services.pricing import get_price +from pretix.control.forms import SplitDateTimeField from pretix.control.forms.widgets import Select2 from pretix.helpers.money import change_decimal_field @@ -565,6 +568,20 @@ class EventCancelForm(forms.Form): initial=False, required=False, ) + gift_card_expires = SplitDateTimeField( + label=_('Gift card validity'), + required=False, + widget=SplitDateTimePickerWidget( + attrs={'data-display-dependency': '#id_refund_as_giftcard'}, + ) + ) + gift_card_conditions = forms.CharField( + label=_('Special terms and conditions'), + required=False, + widget=forms.Textarea( + attrs={'rows': 2, 'data-display-dependency': '#id_refund_as_giftcard'}, + ) + ) keep_fee_fixed = forms.DecimalField( label=_("Keep a fixed cancellation fee"), max_digits=10, decimal_places=2, @@ -615,6 +632,8 @@ class EventCancelForm(forms.Form): def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') + kwargs.setdefault('initial', {}) + kwargs['initial']['gift_card_expires'] = self.event.organizer.default_gift_card_expiry super().__init__(*args, **kwargs) self.fields['send_subject'] = I18nFormField( label=_("Subject"), diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index b89c38a990..304626f463 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -14,9 +14,10 @@ from i18nfield.forms import I18nFormField, I18nTextarea from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.forms import I18nModelForm, SettingsForm +from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import Device, GiftCard, Organizer, Team from pretix.control.forms import ( - ExtFileField, FontSelect, MultipleLanguagesWidget, + ExtFileField, FontSelect, MultipleLanguagesWidget, SplitDateTimeField, ) from pretix.control.forms.event import SafeEventMultipleChoiceField from pretix.multidomain.models import KnownDomain @@ -330,6 +331,12 @@ class OrganizerSettingsForm(SettingsForm): 'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])), required=False ) + giftcard_expiry_years = forms.IntegerField( + label=_('Validity of gift card codes in years'), + help_text=_('If you set a number here, gift cards will by default expire at the end of the year after this ' + 'many years. If you keep it empty, gift cards do not have an explicit expiry date.'), + required=False + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -378,12 +385,15 @@ class GiftCardCreateForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.organizer = kwargs.pop('organizer') + initial = kwargs.pop('initial', {}) + initial['expires'] = self.organizer.default_gift_card_expiry + kwargs['initial'] = initial super().__init__(*args, **kwargs) def clean_secret(self): s = self.cleaned_data['secret'] if GiftCard.objects.filter( - secret__iexact=s + secret__iexact=s ).filter( Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer) ).exists(): @@ -394,4 +404,24 @@ class GiftCardCreateForm(forms.ModelForm): class Meta: model = GiftCard - fields = ['secret', 'currency', 'testmode'] + fields = ['secret', 'currency', 'testmode', 'expires', 'conditions'] + field_classes = { + 'expires': SplitDateTimeField + } + widgets = { + 'expires': SplitDateTimePickerWidget, + 'conditions': forms.Textarea(attrs={"rows": 2}) + } + + +class GiftCardUpdateForm(forms.ModelForm): + class Meta: + model = GiftCard + fields = ['expires', 'conditions'] + field_classes = { + 'expires': SplitDateTimeField + } + widgets = { + 'expires': SplitDateTimePickerWidget, + 'conditions': forms.Textarea(attrs={"rows": 2}) + } diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index bd923bb910..65d313b1c6 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -334,6 +334,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.device.keyroll': _('The access token of the device has been regenerated.'), 'pretix.device.updated': _('The device has notified the server of an hardware or software update.'), 'pretix.giftcards.created': _('The gift card has been created.'), + 'pretix.giftcards.modified': _('The gift card has been changed.'), 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), } diff --git a/src/pretix/control/templates/pretixcontrol/orders/cancel.html b/src/pretix/control/templates/pretixcontrol/orders/cancel.html index dcf9681fa9..9e3aa196da 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/orders/cancel.html @@ -36,6 +36,8 @@ {% bootstrap_field form.auto_refund layout="control" %} {% bootstrap_field form.manual_refund layout="control" %} {% bootstrap_field form.refund_as_giftcard layout="control" %} + {% bootstrap_field form.gift_card_expires layout="control" %} + {% bootstrap_field form.gift_card_conditions layout="control" %} {% bootstrap_field form.keep_fee_fixed layout="control" %} {% bootstrap_field form.keep_fee_percentage layout="control" %} {% bootstrap_field form.keep_fees layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 412a6a4692..0a88406bb7 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -32,7 +32,6 @@ {% endif %} {% bootstrap_field sform.organizer_info_text layout="control" %} {% bootstrap_field sform.event_team_provisioning layout="control" %} - {% bootstrap_field sform.giftcard_length layout="control" %}
+