forked from CGM_Public/pretix_original
@@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -134,6 +134,17 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
if data.get('issue_giftcard'):
|
||||
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
|
||||
raise ValidationError(
|
||||
_("Gift card products should not be associated with non-zero tax rates since sales tax will be "
|
||||
"applied when the gift card is redeemed.")
|
||||
)
|
||||
if data.get('admission'):
|
||||
raise ValidationError(_(
|
||||
"Gift card products should not be admission products at the same time."
|
||||
))
|
||||
|
||||
return data
|
||||
|
||||
def validate_category(self, value):
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
|
||||
|
||||
@@ -18,3 +23,27 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SeatingPlan
|
||||
fields = ('id', 'name', 'layout')
|
||||
|
||||
|
||||
class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
s = data['secret']
|
||||
qs = GiftCard.objects.filter(
|
||||
secret=s
|
||||
).filter(
|
||||
Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
|
||||
)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')}
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')
|
||||
|
||||
@@ -19,6 +19,7 @@ orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from rest_framework import filters, serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.serializers.organizer import (
|
||||
OrganizerSerializer, SeatingPlanSerializer,
|
||||
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
|
||||
)
|
||||
from pretix.base.models import Organizer, SeatingPlan
|
||||
from pretix.base.models import GiftCard, Organizer, SeatingPlan
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
@@ -81,3 +84,66 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
|
||||
class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = GiftCardSerializer
|
||||
queryset = GiftCard.objects.none()
|
||||
permission = 'can_manage_gift_cards'
|
||||
write_permission = 'can_manage_gift_cards'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.issued_gift_cards.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(issuer=self.request.organizer)
|
||||
inst.transactions.create(value=value)
|
||||
inst.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
old_value = serializer.instance.value
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||
testmode=serializer.instance.testmode)
|
||||
diff = value - old_value
|
||||
inst.transactions.create(value=diff)
|
||||
inst.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'value': diff}
|
||||
)
|
||||
return inst
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
@transaction.atomic()
|
||||
def transact(self, request, **kwargs):
|
||||
gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('value')
|
||||
)
|
||||
gc.transactions.create(value=value)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'value': value}
|
||||
)
|
||||
return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
raise MethodNotAllowed("Gift cards cannot be deleted.")
|
||||
|
||||
107
src/pretix/base/migrations/0138_auto_20191017_1151.py
Normal file
107
src/pretix/base/migrations/0138_auto_20191017_1151.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# Generated by Django 2.2.4 on 2019-10-17 11:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
import pretix.base.models.giftcards
|
||||
|
||||
|
||||
def fwd(app, schema_editor):
|
||||
Team = app.get_model('pretixbase', 'Team')
|
||||
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_gift_cards=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0137_auto_20191015_1141'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GiftCard',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('issuance', models.DateTimeField(auto_now_add=True)),
|
||||
('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret,
|
||||
max_length=190)),
|
||||
('currency', models.CharField(max_length=10)),
|
||||
('issued_in', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='issued_gift_cards', to='pretixbase.OrderPosition')),
|
||||
('issuer',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards',
|
||||
to='pretixbase.Organizer')),
|
||||
('testmode', django.db.models.BooleanField(default=False)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('secret', 'issuer')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='issue_giftcard',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='can_manage_gift_cards',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='dependency_values',
|
||||
field=pretix.base.models.fields.MultiStringField(default=[]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='item',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='quota',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.Quota'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='variation',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers',
|
||||
to='pretixbase.ItemVariation'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GiftCardTransaction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions',
|
||||
to='pretixbase.GiftCard')),
|
||||
('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.Order')),
|
||||
('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.OrderPayment')),
|
||||
('refund', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name='gift_card_transactions', to='pretixbase.OrderRefund')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GiftCardAcceptance',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('collector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='gift_card_issuer_acceptance', to='pretixbase.Organizer')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='gift_card_collector_acceptance', to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
fwd, migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
)
|
||||
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
|
||||
|
||||
@@ -335,6 +335,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
def get_organizers_with_permission(self, permission, request=None):
|
||||
"""
|
||||
Returns a queryset of organizers the user has a specific permissions to.
|
||||
|
||||
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||
:return: Iterable of Organizers
|
||||
"""
|
||||
from .event import Organizer
|
||||
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
return Organizer.objects.all()
|
||||
|
||||
kwargs = {permission: True}
|
||||
|
||||
return Organizer.objects.filter(
|
||||
id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True)
|
||||
)
|
||||
|
||||
def has_active_staff_session(self, session_key=None):
|
||||
"""
|
||||
Returns whether or not a user has an active staff session (formerly known as superuser session)
|
||||
|
||||
@@ -730,7 +730,7 @@ class Event(EventMixin, LoggedModel):
|
||||
def has_payment_provider(self):
|
||||
result = False
|
||||
for provider in self.get_payment_providers().values():
|
||||
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
|
||||
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting', 'giftcard'):
|
||||
result = True
|
||||
break
|
||||
return result
|
||||
|
||||
112
src/pretix/base/models/giftcards.py
Normal file
112
src/pretix/base/models/giftcards.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
def gen_giftcard_secret():
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['giftcard_secret'], allowed_chars=charset)
|
||||
if not banned(code) and not GiftCard.objects.filter(secret=code).exists():
|
||||
return code
|
||||
|
||||
|
||||
class GiftCardAcceptance(models.Model):
|
||||
issuer = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='gift_card_collector_acceptance',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
collector = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='gift_card_issuer_acceptance',
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
|
||||
class GiftCard(LoggedModel):
|
||||
issuer = models.ForeignKey(
|
||||
'Organizer',
|
||||
related_name='issued_gift_cards',
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
issued_in = models.ForeignKey(
|
||||
'OrderPosition',
|
||||
related_name='issued_gift_cards',
|
||||
on_delete=models.PROTECT,
|
||||
null=True, blank=True
|
||||
)
|
||||
issuance = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=190,
|
||||
default=gen_giftcard_secret,
|
||||
db_index=True,
|
||||
verbose_name=_('Gift card code'),
|
||||
)
|
||||
testmode = models.BooleanField(
|
||||
verbose_name=_('Test mode card'),
|
||||
default=False
|
||||
)
|
||||
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 value(self):
|
||||
return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00')
|
||||
|
||||
def accepted_by(self, organizer):
|
||||
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists()
|
||||
|
||||
class Meta:
|
||||
unique_together = (('secret', 'issuer'),)
|
||||
|
||||
|
||||
class GiftCardTransaction(models.Model):
|
||||
card = models.ForeignKey(
|
||||
'GiftCard',
|
||||
related_name='transactions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
datetime = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
value = models.DecimalField(
|
||||
decimal_places=2,
|
||||
max_digits=10
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
'Order',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
payment = models.ForeignKey(
|
||||
'OrderPayment',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
refund = models.ForeignKey(
|
||||
'OrderRefund',
|
||||
related_name='gift_card_transactions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("datetime",)
|
||||
@@ -242,6 +242,8 @@ class Item(LoggedModel):
|
||||
:type require_approval: bool
|
||||
:param sales_channels: Sales channels this item is available on.
|
||||
:type sales_channels: bool
|
||||
:param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
|
||||
:type issue_giftcard: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySetManager()
|
||||
@@ -413,6 +415,12 @@ class Item(LoggedModel):
|
||||
verbose_name=_('Sales channels'),
|
||||
default=['web']
|
||||
)
|
||||
issue_giftcard = models.BooleanField(
|
||||
verbose_name=_('This product is a gift card'),
|
||||
help_text=_('When a customer buys this product, they will get a gift card with a value corresponding to the '
|
||||
'product price.'),
|
||||
default=False
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/forms/item.py if applicable.
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ class Order(LockModel, LoggedModel):
|
||||
return self.full_code
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import Voucher
|
||||
from . import Voucher, GiftCard, GiftCardTransaction
|
||||
|
||||
if not self.testmode:
|
||||
raise TypeError("Only test mode orders can be deleted.")
|
||||
@@ -218,6 +218,10 @@ class Order(LockModel, LoggedModel):
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None)
|
||||
GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None)
|
||||
GiftCardTransaction.objects.filter(order=self).update(order=None)
|
||||
GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None)
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
@@ -460,11 +464,15 @@ class Order(LockModel, LoggedModel):
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item')
|
||||
).select_related('item').prefetch_related('issued_gift_cards')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
for op in positions:
|
||||
for gc in op.issued_gift_cards.all():
|
||||
if gc.value != op.price:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
|
||||
@@ -2,6 +2,7 @@ import string
|
||||
|
||||
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.translation import ugettext_lazy as _
|
||||
@@ -82,6 +83,24 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@property
|
||||
def has_gift_cards(self):
|
||||
return self.cache.get_or_set(
|
||||
key='has_gift_cards',
|
||||
timeout=15,
|
||||
default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.exists()
|
||||
)
|
||||
|
||||
@property
|
||||
def accepted_gift_cards(self):
|
||||
from .giftcards import GiftCard, GiftCardAcceptance
|
||||
|
||||
return GiftCard.objects.annotate(
|
||||
accepted=Exists(GiftCardAcceptance.objects.filter(issuer=OuterRef('issuer'), collector=self))
|
||||
).filter(
|
||||
Q(issuer=self) | Q(accepted=True)
|
||||
)
|
||||
|
||||
def allow_delete(self):
|
||||
from . import Order, Invoice
|
||||
return (
|
||||
@@ -156,6 +175,10 @@ class Team(LoggedModel):
|
||||
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
|
||||
'reports, so be careful who you add to this team!')
|
||||
)
|
||||
can_manage_gift_cards = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage gift cards")
|
||||
)
|
||||
|
||||
can_change_event_settings = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -7,7 +7,9 @@ from typing import Any, Dict, Union
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django.forms import Form
|
||||
from django.http import HttpRequest
|
||||
@@ -20,8 +22,8 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund,
|
||||
Quota,
|
||||
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
|
||||
OrderRefund, Quota,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
@@ -29,7 +31,8 @@ from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.presale.views import get_cart_total
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -888,6 +891,206 @@ class OffsettingProvider(BasePaymentProvider):
|
||||
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
|
||||
|
||||
|
||||
class GiftCardPayment(BasePaymentProvider):
|
||||
identifier = "giftcard"
|
||||
verbose_name = _("Gift card")
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
f = super().settings_form_fields
|
||||
del f['_fee_abs']
|
||||
del f['_fee_percent']
|
||||
del f['_fee_reverse_calc']
|
||||
del f['_total_min']
|
||||
del f['_total_max']
|
||||
del f['_invoice_text']
|
||||
return f
|
||||
|
||||
@property
|
||||
def test_mode_message(self) -> str:
|
||||
return _("In test mode, only test cards will work.")
|
||||
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
return super().is_allowed(request, total) and self.event.organizer.has_gift_cards
|
||||
|
||||
def order_change_allowed(self, order: Order) -> bool:
|
||||
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout.html').render({})
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({})
|
||||
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
from .models import GiftCard
|
||||
|
||||
if 'gift_card' in payment.info_data:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'gc': gc,
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def api_payment_details(self, payment: OrderPayment):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
return {
|
||||
'gift_card': {
|
||||
'id': gc.pk,
|
||||
'secret': gc.secret,
|
||||
'organizer': gc.issuer.slug
|
||||
}
|
||||
}
|
||||
|
||||
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return True
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return True
|
||||
|
||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
|
||||
for p in get_cart(request):
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
return
|
||||
|
||||
cs = cart_session(request)
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
return
|
||||
if gc.testmode and not self.event.testmode:
|
||||
messages.error(request, _("This gift card can only be used in test mode."))
|
||||
return
|
||||
if not gc.testmode and self.event.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
messages.error(request, _("All credit on this gift card has been used."))
|
||||
return
|
||||
if 'gift_cards' not in cs:
|
||||
cs['gift_cards'] = []
|
||||
elif gc.pk in cs['gift_cards']:
|
||||
messages.error(request, _("This gift card is already used for your payment."))
|
||||
return
|
||||
cs['gift_cards'] = cs['gift_cards'] + [gc.pk]
|
||||
|
||||
remainder = cart['total'] - gc.value
|
||||
if remainder >= Decimal('0.00'):
|
||||
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(
|
||||
money_filter(remainder, self.event.currency)
|
||||
))
|
||||
else:
|
||||
messages.success(request, _("Your gift card has been applied."))
|
||||
|
||||
kwargs = {'step': 'payment'}
|
||||
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
|
||||
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
|
||||
return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs)
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
"the product selection."))
|
||||
else:
|
||||
messages.error(request, _("This gift card is not known."))
|
||||
except GiftCard.MultipleObjectsReturned:
|
||||
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||
|
||||
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]:
|
||||
for p in payment.order.positions.all():
|
||||
if p.item.issue_giftcard:
|
||||
messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
|
||||
return
|
||||
|
||||
try:
|
||||
gc = self.event.organizer.accepted_gift_cards.get(
|
||||
secret=request.POST.get("giftcard")
|
||||
)
|
||||
if gc.currency != self.event.currency:
|
||||
messages.error(request, _("This gift card does not support this currency."))
|
||||
return
|
||||
if gc.testmode and not payment.order.testmode:
|
||||
messages.error(request, _("This gift card can only be used in test mode."))
|
||||
return
|
||||
if not gc.testmode and payment.order.testmode:
|
||||
messages.error(request, _("Only test gift cards can be used in test mode."))
|
||||
return
|
||||
if gc.value <= Decimal("0.00"):
|
||||
messages.error(request, _("All credit on this gift card has been used."))
|
||||
return
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'retry': True
|
||||
}
|
||||
payment.amount = min(payment.amount, gc.value)
|
||||
payment.save()
|
||||
|
||||
return True
|
||||
except GiftCard.DoesNotExist:
|
||||
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
|
||||
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||
"the product selection."))
|
||||
else:
|
||||
messages.error(request, _("This gift card is not known."))
|
||||
except GiftCard.MultipleObjectsReturned:
|
||||
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
# This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called
|
||||
# during the order creation phase because this payment provider is a special case.
|
||||
for p in payment.order.positions.all(): # noqa - just a safeguard
|
||||
if p.item.issue_giftcard:
|
||||
raise PaymentException(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
gcpk = payment.info_data.get('gift_card')
|
||||
if not gcpk or not payment.info_data.get('retry'):
|
||||
raise PaymentException("Invalid state, should never occur.")
|
||||
with transaction.atomic():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gcpk)
|
||||
if gc.currency != self.event.currency: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
|
||||
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"))
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * payment.amount,
|
||||
order=payment.order,
|
||||
payment=payment
|
||||
)
|
||||
payment.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
payment.confirm()
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
return True
|
||||
|
||||
@transaction.atomic()
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
from .models import GiftCard
|
||||
gc = GiftCard.objects.get(pk=refund.payment.info_data.get('gift_card'))
|
||||
trans = gc.transactions.create(
|
||||
value=refund.amount,
|
||||
order=refund.order,
|
||||
refund=refund
|
||||
)
|
||||
refund.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
refund.done()
|
||||
|
||||
|
||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||
def register_payment_provider(sender, **kwargs):
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment]
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
|
||||
|
||||
@@ -97,6 +97,7 @@ error_messages = {
|
||||
'seat_forbidden': _('You can not select a seat for this position.'),
|
||||
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
|
||||
'seat_multiple': _('You can not select the same seat multiple times.'),
|
||||
'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
}
|
||||
|
||||
|
||||
@@ -958,14 +959,41 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
|
||||
|
||||
def get_fees(event, request, total, invoice_address, provider):
|
||||
fees = []
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
fees = []
|
||||
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
||||
total=total):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total = total + sum(f.value for f in fees)
|
||||
|
||||
cs = cart_session(request)
|
||||
if cs.get('gift_cards'):
|
||||
gcs = cs['gift_cards']
|
||||
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
|
||||
summed = 0
|
||||
for gc in gc_qs:
|
||||
if gc.testmode != event.testmode:
|
||||
gcs.remove(gc.pk)
|
||||
continue
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total - summed)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
summed += fval
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
|
||||
internal_type='giftcard',
|
||||
description=gc.secret,
|
||||
value=-1 * fval,
|
||||
tax_rate=Decimal('0.00'),
|
||||
tax_value=Decimal('0.00'),
|
||||
tax_rule=TaxRule.zero()
|
||||
))
|
||||
cs['gift_cards'] = gcs
|
||||
|
||||
if provider and total != 0:
|
||||
provider = event.get_payment_providers().get(provider)
|
||||
if provider:
|
||||
|
||||
@@ -20,8 +20,9 @@ from pretix.api.models import OAuthApplication
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
|
||||
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
@@ -43,8 +44,8 @@ from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.signals import (
|
||||
allow_ticket_download, order_approved, order_canceled, order_changed,
|
||||
order_denied, order_expired, order_fee_calculation, order_placed,
|
||||
order_split, periodic_task, validate_order,
|
||||
order_denied, order_expired, order_fee_calculation, order_paid,
|
||||
order_placed, order_split, periodic_task, validate_order,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -291,6 +292,19 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
for position in order.positions.all():
|
||||
for gc in position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
if gc.value < position.price:
|
||||
raise OrderError(
|
||||
_('This order can not be canceled since the gift card {card} purchased in '
|
||||
'this order has already been redeemed.').format(
|
||||
card=gc.secret
|
||||
)
|
||||
)
|
||||
else:
|
||||
gc.transactions.create(value=-position.price, order=order)
|
||||
|
||||
if cancellation_fee:
|
||||
with order.event.lock():
|
||||
for position in order.positions.all():
|
||||
@@ -545,7 +559,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
|
||||
|
||||
def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress,
|
||||
meta_info: dict, event: Event):
|
||||
meta_info: dict, event: Event, gift_cards: List[GiftCard]):
|
||||
fees = []
|
||||
total = sum([c.price for c in positions])
|
||||
|
||||
@@ -553,8 +567,16 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
||||
meta_info=meta_info, positions=positions):
|
||||
if resp:
|
||||
fees += resp
|
||||
|
||||
total += sum(f.value for f in fees)
|
||||
|
||||
gift_card_values = {}
|
||||
for gc in gift_cards:
|
||||
fval = Decimal(gc.value) # TODO: don't require an extra query
|
||||
fval = min(fval, total)
|
||||
if fval > 0:
|
||||
total -= fval
|
||||
gift_card_values[gc] = fval
|
||||
|
||||
if payment_provider:
|
||||
payment_fee = payment_provider.calculate_fee(total)
|
||||
else:
|
||||
@@ -565,17 +587,34 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
||||
internal_type=payment_provider.identifier)
|
||||
fees.append(pf)
|
||||
|
||||
return fees, pf
|
||||
return fees, pf, gift_card_values
|
||||
|
||||
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||
meta_info: dict=None, sales_channel: str='web'):
|
||||
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
|
||||
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None,
|
||||
shown_total=None):
|
||||
p = None
|
||||
|
||||
with transaction.atomic():
|
||||
checked_gift_cards = []
|
||||
if gift_cards:
|
||||
gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards)
|
||||
for gc in gc_qs:
|
||||
if gc.currency != event.currency:
|
||||
raise OrderError(_("This gift card does not support this currency."))
|
||||
if gc.testmode and not event.testmode:
|
||||
raise OrderError(_("This gift card can only be used in test mode."))
|
||||
if not gc.testmode and event.testmode:
|
||||
raise OrderError(_("Only test gift cards can be used in test mode."))
|
||||
if not gc.accepted_by(event.organizer):
|
||||
raise OrderError(_("This gift card is not accepted by this event organizer."))
|
||||
checked_gift_cards.append(gc)
|
||||
if checked_gift_cards and any(c.item.issue_giftcard for c in positions):
|
||||
raise OrderError(_("You cannot pay with gift cards when buying a gift card."))
|
||||
|
||||
fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards)
|
||||
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
|
||||
|
||||
order = Order(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
@@ -606,11 +645,41 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
for gc, val in gift_card_values.items():
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='giftcard',
|
||||
amount=val,
|
||||
fee=pf
|
||||
)
|
||||
trans = gc.transactions.create(
|
||||
value=-1 * val,
|
||||
order=order,
|
||||
payment=p
|
||||
)
|
||||
p.info_data = {
|
||||
'gift_card': gc.pk,
|
||||
'transaction_id': trans.pk,
|
||||
}
|
||||
p.save()
|
||||
pending_sum -= val
|
||||
|
||||
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
|
||||
# pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again.
|
||||
# The only *known* case where this happens is if a gift card is used in two concurrent sessions.
|
||||
if shown_total is not None:
|
||||
if Decimal(shown_total) != pending_sum:
|
||||
raise OrderError(
|
||||
_('While trying to place your order, we noticed that the order total has changed. Either one of '
|
||||
'the prices changed just now, or a gift card you used has been used in the meantime. Please '
|
||||
'check the prices below and try again.')
|
||||
)
|
||||
|
||||
if payment_provider and not order.require_approval:
|
||||
p = order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
amount=total,
|
||||
amount=pending_sum,
|
||||
fee=pf
|
||||
)
|
||||
|
||||
@@ -658,7 +727,8 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
|
||||
|
||||
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
gift_cards: list=None, shown_total=None):
|
||||
if payment_provider:
|
||||
pprov = event.get_payment_providers().get(payment_provider)
|
||||
if not pprov:
|
||||
@@ -707,9 +777,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
raise OrderError(error_messages['internal'])
|
||||
_check_positions(event, now_dt, positions, address=addr)
|
||||
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
gift_cards=gift_cards, shown_total=shown_total)
|
||||
|
||||
free_order_flow = payment and payment_provider == 'free' and order.total == Decimal('0.00') and not order.require_approval
|
||||
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
|
||||
if free_order_flow:
|
||||
try:
|
||||
payment.confirm(send_mail=False, lock=not locked)
|
||||
@@ -894,6 +965,7 @@ class OrderChangeManager:
|
||||
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
|
||||
'seat_required': _('The selected product requires you to select a seat.'),
|
||||
'seat_forbidden': _('The selected product does not allow to select a seat.'),
|
||||
'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
|
||||
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
|
||||
@@ -961,6 +1033,9 @@ class OrderChangeManager:
|
||||
def change_price(self, position: OrderPosition, price: Decimal):
|
||||
price = position.item.tax(price, base_price_is='gross')
|
||||
|
||||
if position.issued_gift_cards.exists():
|
||||
raise OrderError(self.error_messages['gift_card_change'])
|
||||
|
||||
self._totaldiff += price.gross - position.price
|
||||
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
|
||||
@@ -1188,6 +1263,17 @@ class OrderChangeManager:
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for gc in op.position.issued_gift_cards.all():
|
||||
gc = GiftCard.objects.select_for_update().get(pk=gc.pk)
|
||||
if gc.value < op.position.price:
|
||||
raise OrderError(_(
|
||||
'A position can not be canceled since the gift card {card} purchased in this order has '
|
||||
'already been redeemed.').format(
|
||||
card=gc.secret
|
||||
))
|
||||
else:
|
||||
gc.transactions.create(value=-op.position.price, order=self.order)
|
||||
|
||||
for opa in op.position.addons.all():
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': opa.pk,
|
||||
@@ -1466,12 +1552,12 @@ class OrderChangeManager:
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web'):
|
||||
sales_channel: str='web', gift_cards: list=None, shown_total=None):
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
||||
sales_channel)
|
||||
sales_channel, gift_cards, shown_total)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
@@ -1615,3 +1701,25 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
generate_invoice(order)
|
||||
|
||||
return old_fee, new_fee, fee, new_payment
|
||||
|
||||
|
||||
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards")
|
||||
@transaction.atomic()
|
||||
def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
|
||||
any_giftcards = False
|
||||
for p in order.positions.all():
|
||||
if p.item.issue_giftcard:
|
||||
issued = Decimal('0.00')
|
||||
for gc in p.issued_gift_cards.all():
|
||||
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
|
||||
)
|
||||
gc.transactions.create(value=p.price - issued, order=order)
|
||||
any_giftcards = True
|
||||
p.secret = gc.secret
|
||||
p.save(update_fields=['secret'])
|
||||
|
||||
if any_giftcards:
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk})
|
||||
|
||||
@@ -133,6 +133,10 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'payment_giftcard__enabled': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'payment_term_accept_late': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
|
||||
@@ -502,6 +502,29 @@ class OrganizerFilterForm(FilterForm):
|
||||
return qs
|
||||
|
||||
|
||||
class GiftCardFilterForm(FilterForm):
|
||||
query = forms.CharField(
|
||||
label=_('Search query'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Search query'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('query'):
|
||||
query = fdata.get('query')
|
||||
qs = qs.filter(secret__icontains=query)
|
||||
return qs
|
||||
|
||||
|
||||
class EventFilterForm(FilterForm):
|
||||
orders = {
|
||||
'slug': 'slug',
|
||||
|
||||
@@ -421,6 +421,23 @@ class ItemUpdateForm(I18nModelForm):
|
||||
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
|
||||
self.fields['hidden_if_available'].required = False
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d['issue_giftcard']:
|
||||
if d['tax_rule'] and d['tax_rule'].rate > 0:
|
||||
self.add_error(
|
||||
'tax_rule',
|
||||
_("Gift card products should not be associated with non-zero tax rates since sales tax will be applied when the gift card is redeemed.")
|
||||
)
|
||||
if d['admission']:
|
||||
self.add_error(
|
||||
'admission',
|
||||
_(
|
||||
"Gift card products should not be admission products at the same time."
|
||||
)
|
||||
)
|
||||
return d
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
localized_fields = '__all__'
|
||||
@@ -451,6 +468,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'require_bundling',
|
||||
'show_quota_left',
|
||||
'hidden_if_available',
|
||||
'issue_giftcard',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
|
||||
@@ -4,6 +4,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
@@ -12,7 +13,7 @@ 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.models import Device, Organizer, Team
|
||||
from pretix.base.models import Device, GiftCard, Organizer, Team
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget,
|
||||
)
|
||||
@@ -145,6 +146,7 @@ class TeamForm(forms.ModelForm):
|
||||
model = Team
|
||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||
'can_change_teams', 'can_change_organizer_settings',
|
||||
'can_manage_gift_cards',
|
||||
'can_change_event_settings', 'can_change_items',
|
||||
'can_view_orders', 'can_change_orders',
|
||||
'can_view_vouchers', 'can_change_vouchers']
|
||||
@@ -328,3 +330,29 @@ class WebHookForm(forms.ModelForm):
|
||||
field_classes = {
|
||||
'limit_events': SafeModelMultipleChoiceField
|
||||
}
|
||||
|
||||
|
||||
class GiftCardCreateForm(forms.ModelForm):
|
||||
value = forms.DecimalField(
|
||||
label=_('Gift card value')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_secret(self):
|
||||
s = self.cleaned_data['secret']
|
||||
if GiftCard.objects.filter(
|
||||
secret__iexact=s
|
||||
).filter(
|
||||
Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer)
|
||||
).exists():
|
||||
raise ValidationError(
|
||||
_('A gift card with the same secret already exists in your or an affiliated organizer account.')
|
||||
)
|
||||
return s
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ['secret', 'currency', 'testmode']
|
||||
|
||||
@@ -437,6 +437,16 @@ def get_organizer_navigation(request):
|
||||
'active': 'organizer.device' in url.url_name,
|
||||
'icon': 'tablet',
|
||||
})
|
||||
if 'can_manage_gift_cards' in request.orgapermset:
|
||||
nav.append({
|
||||
'label': _('Gift cards'),
|
||||
'url': reverse('control:organizer.giftcards', kwargs={
|
||||
'organizer': request.organizer.slug
|
||||
}),
|
||||
'active': 'organizer.giftcard' in url.url_name,
|
||||
'icon': 'credit-card',
|
||||
})
|
||||
if 'can_change_organizer_settings' in request.orgapermset:
|
||||
nav.append({
|
||||
'label': _('Webhooks'),
|
||||
'url': reverse('control:organizer.webhooks', kwargs={
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
If you have a gift card, please enter the gift card code here. If the gift card does not have
|
||||
enough credit to pay for the full order, you will be shown this page again and you can either
|
||||
redeem another gift card or select a different payment method for the difference.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<input name="giftcard" class="form-control" placeholder="{% trans "Gift card code" %}">
|
||||
@@ -0,0 +1,8 @@
|
||||
{% load i18n %}
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
Your gift card will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the
|
||||
difference with a different payment method. If the credit is higher than the order total, you will be able to re-use the gift card in the future.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
@@ -0,0 +1,12 @@
|
||||
{% load i18n %}
|
||||
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
|
||||
{{ gc.secret }}
|
||||
</a>
|
||||
</dd>
|
||||
<dt>{% trans "Issuer" %}</dt>
|
||||
<dd>{{ gc.issuer }}</dd>
|
||||
</dl>
|
||||
@@ -55,6 +55,7 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Additional settings" %}</legend>
|
||||
{% bootstrap_field form.issue_giftcard layout="control" %}
|
||||
{% bootstrap_field form.show_quota_left layout="control" %}
|
||||
{% for f in plugin_forms %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
|
||||
@@ -310,6 +310,14 @@
|
||||
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if line.issued_gift_cards %}
|
||||
<dl>
|
||||
{% for gc in line.issued_gift_cards.all %}
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd><a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">{{ gc.secret }}</a></dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% blocktrans trimmed with card=card.secret %}
|
||||
Gift card: {{ card }}
|
||||
{% endblocktrans %}
|
||||
{% if card.testmode %}
|
||||
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<div class="panel panel-primary items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Details" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd>{{ card.secret }}</dd>
|
||||
<dt>{% trans "Creation date" %}</dt>
|
||||
<dd>{{ card.issuance|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||
<dt>{% trans "Current value" %}</dt>
|
||||
<dd>{{ card.value|money:card.currency }}</dd>
|
||||
<dt>{% trans "Currency" %}</dt>
|
||||
<dd>{{ card.currency }}</dd>
|
||||
{% if card.issued_in %}
|
||||
<dt>{% trans "Issued through sale" %}</dt>
|
||||
<dd>
|
||||
<a href="{% url "control:event.order" event=card.issued_in.order.event.slug organizer=card.issued_in.order.event.organizer.slug code=card.issued_in.order.code %}">
|
||||
{{ card.issued_in.order.full_code }}</a>-{{ card.issued_in.positionid }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Transactions" %}
|
||||
</h3>
|
||||
</div>
|
||||
<table class="panel-body table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Order" %}</th>
|
||||
<th class="text-right">{% trans "Value" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in card.transactions.all %}
|
||||
<tr>
|
||||
<td>{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>
|
||||
{% if t.order %}
|
||||
<a href="{% url "control:event.order" event=t.order.event.slug organizer=t.order.event.organizer.slug code=t.order.code %}">
|
||||
{{ t.order.full_code }}
|
||||
</a>
|
||||
{% else %}
|
||||
<em>{% trans "Manual transaction" %}</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ t.value|money:card.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="text-right">
|
||||
<form class="helper-display-inline form-inline" method="post" action="">
|
||||
{% csrf_token %}
|
||||
<input type="text" class="form-control input-sm" placeholder="{% trans "Value" %}" name="value">
|
||||
<button class="btn btn-primary">
|
||||
<span class="fa fa-plus"></span>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "Create a new gift card" %}</h1>
|
||||
<form class="form-horizontal" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.secret layout="control" %}
|
||||
{% bootstrap_field form.value layout="control" %}
|
||||
{% bootstrap_field form.currency layout="control" %}
|
||||
{% bootstrap_field form.testmode layout="control" %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% trans "Issued gift cards" %}
|
||||
</h1>
|
||||
{% if giftcards|length == 0 and not filter_form.filtered %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't issued any gift cards yet. You can either set up a product in an event shop to sell gift cards,
|
||||
or you can manually issue gift cards.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:organizer.giftcard.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default btn-lg"><i class="fa fa-plus"></i> {% trans "Manually issue a gift card" %}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="row filter-form" action="" method="get">
|
||||
<div class="col-md-10 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
<button class="btn btn-primary btn-block" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
<span class="hidden-md">
|
||||
{% trans "Filter" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p>
|
||||
<a href="{% url "control:organizer.giftcard.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Manually issue a gift card" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Gift card code" %}</th>
|
||||
<th>{% trans "Creation date" %}</th>
|
||||
<th class="text-right">{% trans "Current value" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for g in giftcards %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=g.id %}">
|
||||
<strong>{{ g.secret }}</strong></a>
|
||||
{% if g.testmode %}
|
||||
<span class="label label-warning">{% trans "TEST MODE" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ g.issuance|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td class="text-right">
|
||||
{{ g.cached_value|money:g.currency }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=g.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>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% if not is_paginated or page_obj.number == 1 %}
|
||||
<form action="" method="post" class="form-inline">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Accepted gift cards of other organizers" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you have access to multiple organizer accounts, you can configure that ticket shops in
|
||||
this account will also accept gift codes issued through a different organizer account, and
|
||||
vice versa.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for gca in request.organizer.gift_card_issuer_acceptance.all %}
|
||||
<li>
|
||||
<strong>{{ gca.issuer }}</strong>
|
||||
<button type="submit" name="del" value="{{ gca.issuer.slug }}" class="btn btn-xs btn-danger">
|
||||
<span class="fa fa-trash"></span>
|
||||
</button>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>
|
||||
<em>{% trans "You are currently not accepting gift cards from other organizers." %}</em>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if other_organizers %}
|
||||
<li>
|
||||
<select name="add" class="form-control input-sm">
|
||||
<option></option>
|
||||
{% for o in other_organizers %}
|
||||
<option value="{{ o.slug }}">{{ o }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" type="submit"><span class="fa fa-plus"></span></button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</fieldset>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -22,6 +22,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Organizer permissions" %}</legend>
|
||||
{% bootstrap_field form.can_create_events layout="control" %}
|
||||
{% bootstrap_field form.can_manage_gift_cards layout="control" %}
|
||||
{% bootstrap_field form.can_change_teams layout="control" %}
|
||||
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
|
||||
</fieldset>
|
||||
|
||||
@@ -75,6 +75,9 @@ urlpatterns = [
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
||||
name='organizer.display'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
|
||||
name='organizer.webhook.add'),
|
||||
|
||||
@@ -237,7 +237,7 @@ class OrderDetail(OrderView):
|
||||
).select_related(
|
||||
'item', 'variation', 'addon_to', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'item__questions',
|
||||
'item__questions', 'issued_gift_cards',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
||||
'checkins', 'checkins__list'
|
||||
).order_by('positionid')
|
||||
@@ -897,23 +897,26 @@ class OrderTransition(OrderView):
|
||||
else:
|
||||
messages.success(self.request, _('The payment has been created successfully.'))
|
||||
elif self.order.cancel_allowed() and to == 'c' and self.mark_canceled_form.is_valid():
|
||||
cancel_order(self.order, user=self.request.user,
|
||||
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
|
||||
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
|
||||
self.order.refresh_from_db()
|
||||
try:
|
||||
cancel_order(self.order, user=self.request.user,
|
||||
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
|
||||
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
self.order.refresh_from_db()
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format(
|
||||
self.order.pending_sum * -1
|
||||
))
|
||||
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format(
|
||||
self.order.pending_sum * -1
|
||||
))
|
||||
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'e':
|
||||
mark_order_expired(self.order, user=self.request.user)
|
||||
messages.success(self.request, _('The order has been marked as expired.'))
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Max, Min, ProtectedError
|
||||
from django.db.models import Count, DecimalField, Max, Min, ProtectedError, Sum
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.forms import inlineformset_factory
|
||||
from django.http import JsonResponse
|
||||
@@ -21,14 +22,17 @@ from django.views.generic import (
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.models import Device, Organizer, Team, TeamInvite, User
|
||||
from pretix.base.models import (
|
||||
Device, GiftCard, Organizer, Team, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.event import Event, EventMetaProperty
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.control.forms.filter import OrganizerFilterForm
|
||||
from pretix.control.forms.filter import GiftCardFilterForm, OrganizerFilterForm
|
||||
from pretix.control.forms.organizer import (
|
||||
DeviceForm, EventMetaPropertyForm, OrganizerDeleteForm, OrganizerForm,
|
||||
OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm,
|
||||
DeviceForm, EventMetaPropertyForm, GiftCardCreateForm, OrganizerDeleteForm,
|
||||
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||
WebHookForm,
|
||||
)
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
|
||||
@@ -337,7 +341,7 @@ class OrganizerCreate(CreateView):
|
||||
ret = super().form_valid(form)
|
||||
t = Team.objects.create(
|
||||
organizer=form.instance, name=_('Administrators'),
|
||||
all_events=True, can_create_events=True, can_change_teams=True,
|
||||
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
|
||||
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
|
||||
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
|
||||
)
|
||||
@@ -898,3 +902,145 @@ class WebHookLogsView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
|
||||
|
||||
def get_queryset(self):
|
||||
return self.webhook.calls.order_by('-datetime')
|
||||
|
||||
|
||||
class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||
model = GiftCard
|
||||
template_name = 'pretixcontrol/organizers/giftcards.html'
|
||||
permission = 'can_manage_gift_cards'
|
||||
context_object_name = 'giftcards'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.organizer.issued_gift_cards.annotate(
|
||||
cached_value=Sum('transactions__value')
|
||||
)
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
return qs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "add" in request.POST:
|
||||
o = self.request.user.get_organizers_with_permission(
|
||||
'can_manage_gift_cards', self.request
|
||||
).exclude(pk=self.request.organizer.pk).filter(
|
||||
slug=request.POST.get("add")
|
||||
).first()
|
||||
if o:
|
||||
self.request.organizer.gift_card_issuer_acceptance.get_or_create(
|
||||
issuer=o
|
||||
)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.giftcards.acceptance.added',
|
||||
data={'issuer': o.slug},
|
||||
user=request.user
|
||||
)
|
||||
messages.success(self.request, _('The selected gift card issuer has been added.'))
|
||||
if "del" in request.POST:
|
||||
o = Organizer.objects.filter(
|
||||
slug=request.POST.get("del")
|
||||
).first()
|
||||
if o:
|
||||
self.request.organizer.gift_card_issuer_acceptance.filter(
|
||||
issuer=o
|
||||
).delete()
|
||||
self.request.organizer.log_action(
|
||||
'pretix.giftcards.acceptance.removed',
|
||||
data={'issuer': o.slug},
|
||||
user=request.user
|
||||
)
|
||||
messages.success(self.request, _('The selected gift card issuer has been removed.'))
|
||||
return redirect(reverse('control:organizer.giftcards', kwargs={'organizer': self.request.organizer.slug}))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['filter_form'] = self.filter_form
|
||||
ctx['other_organizers'] = self.request.user.get_organizers_with_permission(
|
||||
'can_manage_gift_cards', self.request
|
||||
).exclude(pk=self.request.organizer.pk)
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return GiftCardFilterForm(data=self.request.GET, request=self.request)
|
||||
|
||||
|
||||
class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||
template_name = 'pretixcontrol/organizers/giftcard.html'
|
||||
permission = 'can_manage_gift_cards'
|
||||
context_object_name = 'card'
|
||||
|
||||
def get_object(self, queryset=None) -> Organizer:
|
||||
return get_object_or_404(
|
||||
self.request.organizer.issued_gift_cards,
|
||||
pk=self.kwargs.get('giftcard')
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
|
||||
if 'value' in request.POST:
|
||||
try:
|
||||
value = DecimalField().to_python(request.POST.get('value'))
|
||||
except ValidationError:
|
||||
messages.error(request, _('Your input was invalid, please try again.'))
|
||||
else:
|
||||
if self.object.value + value < Decimal('0.00'):
|
||||
messages.error(request, _('Gift cards are not allowed to have negative values.'))
|
||||
else:
|
||||
self.object.transactions.create(
|
||||
value=value
|
||||
)
|
||||
self.object.log_action(
|
||||
'pretix.giftcards.transaction.manual',
|
||||
data={
|
||||
'value': value
|
||||
},
|
||||
user=self.request.user,
|
||||
)
|
||||
messages.success(request, _('The manual transaction has been saved.'))
|
||||
return redirect(reverse(
|
||||
'control:organizer.giftcard',
|
||||
kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
'giftcard': self.object.pk
|
||||
}
|
||||
))
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||
template_name = 'pretixcontrol/organizers/giftcard_create.html'
|
||||
permission = 'can_manage_gift_cards'
|
||||
form_class = GiftCardCreateForm
|
||||
success_url = 'invalid'
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
any_event = self.request.organizer.events.first()
|
||||
kwargs['initial'] = {
|
||||
'currency': any_event.currency if any_event else settings.DEFAULT_CURRENCY
|
||||
}
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
@transaction.atomic()
|
||||
def post(self, request, *args, **kwargs):
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, _('The gift card has been created and can now be used.'))
|
||||
form.instance.issuer = self.request.organizer
|
||||
super().form_valid(form)
|
||||
form.instance.transactions.create(
|
||||
value=form.cleaned_data['value']
|
||||
)
|
||||
form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={
|
||||
'value': form.cleaned_data['value']
|
||||
})
|
||||
return redirect(reverse(
|
||||
'control:organizer.giftcard',
|
||||
kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'giftcard': self.object.pk
|
||||
}
|
||||
))
|
||||
|
||||
@@ -632,6 +632,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
ctx['cart_session'] = self.cart_session
|
||||
ctx['invoice_address_asked'] = self.address_asked
|
||||
|
||||
self.cart_session['shown_total'] = str(ctx['cart']['total'])
|
||||
|
||||
email = self.cart_session.get('contact_form_data', {}).get('email')
|
||||
if email != settings.PRETIX_EMAIL_NONE_VALUE:
|
||||
ctx['contact_info'] = [
|
||||
@@ -709,7 +711,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None,
|
||||
[p.id for p in self.positions], self.cart_session.get('email'),
|
||||
translation.get_language(), self.invoice_address.pk, meta_info,
|
||||
request.sales_channel)
|
||||
request.sales_channel, self.cart_session.get('gift_cards'),
|
||||
self.cart_session.get('shown_total'))
|
||||
|
||||
def get_success_message(self, value):
|
||||
create_empty_cart_id(self.request)
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
{% if show_fees %}
|
||||
<strong class="pull-right flip">{% if p.fee < 0 %}-{% else %}+{% endif %} {{ p.fee|money:event.currency|cut:"-" }}</strong>
|
||||
<strong class="pull-right flip">{% if p.fee < 0 %}-{% else %}+{% endif %} {{ p.fee|money:event.currency|cut:"-" }}</strong>
|
||||
{% endif %}
|
||||
<input type="radio" name="payment" value="{{ p.provider.identifier }}"
|
||||
title="{{ p.provider.public_name }}"
|
||||
data-parent="#payment_accordion"
|
||||
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
|
||||
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/>
|
||||
title="{{ p.provider.public_name }}"
|
||||
data-parent="#payment_accordion"
|
||||
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
|
||||
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/>
|
||||
<strong>{{ p.provider.public_name }}</strong>
|
||||
</h4>
|
||||
</div>
|
||||
</label>
|
||||
<div id="payment_{{ p.provider.identifier }}"
|
||||
class="panel-collapse collapsed {% if selected == p.provider.identifier %}in{% endif %}">
|
||||
class="panel-collapse collapsed {% if selected == p.provider.identifier %}in{% endif %}">
|
||||
<div class="panel-body form-horizontal">
|
||||
{% if request.event.testmode %}
|
||||
{% if p.provider.test_mode_message %}
|
||||
|
||||
@@ -33,7 +33,14 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if line.issued_gift_cards %}
|
||||
<dl>
|
||||
{% for gc in line.issued_gift_cards.all %}
|
||||
<dt>{% trans "Gift card code" %}</dt>
|
||||
<dd>{{ gc.secret }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
|
||||
@@ -128,6 +128,26 @@
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event download=can_download editable=False %}
|
||||
{% if order.status == "n" and order.total > pending_sum %}
|
||||
<div class="row cart-row">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{% trans "Successful payments" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ payment_sum_neg|money:event.currency }}</strong>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="row cart-row total">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{% trans "Pending total" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ pending_sum|money:event.currency }}</strong>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% eventsignal event "pretix.presale.signals.order_info" order=order %}
|
||||
|
||||
@@ -478,7 +478,10 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView):
|
||||
if v_avail < 1 and not err:
|
||||
err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time
|
||||
except Voucher.DoesNotExist:
|
||||
err = error_messages['voucher_invalid']
|
||||
if self.request.event.organizer.accepted_gift_cards.filter(secret__iexact=request.GET.get("voucher")).exists():
|
||||
err = error_messages['gift_card']
|
||||
else:
|
||||
err = error_messages['voucher_invalid']
|
||||
else:
|
||||
return redirect(self.get_index_url())
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cart'] = self.get_cart(
|
||||
answers=True, downloads=ctx['can_download'],
|
||||
queryset=self.order.positions.select_related('tax_rule'),
|
||||
queryset=self.order.positions.prefetch_related('issued_gift_cards').select_related('tax_rule'),
|
||||
order=self.order
|
||||
)
|
||||
ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and (
|
||||
@@ -193,6 +193,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
|
||||
|
||||
if self.order.status == Order.STATUS_PENDING:
|
||||
ctx['pending_sum'] = self.order.pending_sum
|
||||
ctx['payment_sum_neg'] = ctx['pending_sum'] - self.order.total
|
||||
|
||||
lp = self.order.payments.last()
|
||||
ctx['can_pay'] = False
|
||||
|
||||
@@ -241,6 +241,7 @@ ENTROPY = {
|
||||
'order_code': config.getint('entropy', 'order_code', fallback=5),
|
||||
'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32),
|
||||
'voucher_code': config.getint('entropy', 'voucher_code', fallback=16),
|
||||
'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12),
|
||||
}
|
||||
|
||||
# Internal settings
|
||||
|
||||
Reference in New Issue
Block a user