Merge pull request #1396 from pretix/giftcard

Support for gift cards
This commit is contained in:
Raphael Michel
2019-10-18 17:08:45 +02:00
committed by GitHub
58 changed files with 2677 additions and 72 deletions

View File

@@ -0,0 +1,229 @@
.. _`rest-giftcards`:
Gift cards
==========
Resource description
--------------------
The gift card resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the gift card
secret string Gift card code (can not be modified later)
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
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
Returns a list of all gift cards issued by a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "13.37"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/giftcards/(id)/
Returns information on one gift card, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "13.37"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the gift card to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/giftcards/
Creates a new gift card
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"value": "13.37"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"testmode": false,
"currency": "EUR",
"value": "13.37"
}
:param organizer: The ``slug`` field of the organizer to create a gift card for
:statuscode 201: no error
:statuscode 400: The gift card could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/giftcards/(id)/
Update a gift card. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id``, ``secret``, ``testmode``, and ``currency`` fields. Be
careful when modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method
described below.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"value": "14.00"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"testmode": false,
"currency": "EUR",
"value": "14.00"
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:statuscode 200: no error
:statuscode 400: The gift card could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:post:: /api/v1/organizers/(organizer)/giftcards/(id)/transact/
Atomically change the value of a gift card. A positive amount will increase the value of the gift card,
a negative amount will decrease it.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"value": "2.00"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "15.37"
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:statuscode 200: no error
:statuscode 400: The gift card could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.

View File

@@ -21,6 +21,7 @@ Resources and endpoints
vouchers vouchers
checkinlists checkinlists
waitinglist waitinglist
giftcards
carts carts
webhooks webhooks
seatingplans seatingplans

View File

@@ -77,6 +77,7 @@ generate_tickets boolean If ``false``, t
rules apply. rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out. product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
show_quota_left boolean Publicly show how many tickets are still available. show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used. If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations. has_variations boolean Shows whether or not this item has variations.
@@ -206,6 +207,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -300,6 +302,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -375,6 +378,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -437,6 +441,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
@@ -531,6 +536,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,

View File

@@ -769,6 +769,8 @@ Creating orders
* does not support file upload questions * does not support file upload questions
* does not support redeeming gift cards
You can supply the following fields of the resource: You can supply the following fields of the resource:
* ``code`` (optional) * ``code`` (optional)

View File

@@ -41,6 +41,7 @@ formsets
frontend frontend
frontpage frontpage
gettext gettext
giftcard
gunicorn gunicorn
guid guid
hardcoded hardcoded

View File

@@ -0,0 +1,71 @@
.. spelling::
Warengutschein
Wertgutschein
Gift cards
==========
Gift cards, also known as "gift coupons" or "gift certificates" are a mechanism that allows you to sell tokens that
can later be used to pay for tickets.
Gift cards are very different feature than **vouchers**. The difference is:
* Vouchers can be used to give a discount. When a voucher is used, the price of a ticket is reduced by the configured
discount and sold at a lower price. They therefore reduce both revenue as well as taxes. Vouchers (in pretix) are
always specific to a certain product in an order. Vouchers are usually not sold but given out as part of a
marketing campaign or to specific groups of people. Vouchers in pretix are bound to a specific event.
* Gift cards are not a discount, but rather a means of payment. If you buy a €20 ticket with a €10 gift card, it is
still a €20 ticket and will still count towards your revenue with €20. Gift cards are usually bought for the money
that they are worth. Gift cards in pretix can be used across events (and even organizers).
Selling gift cards
------------------
Selling gift cards works like selling every other type of product in pretix: Create a new product, then head to
"Additional settings" and select the option "This product is a gift card". Whenever someone buys this product and
pays for it, a new gift card will be created.
In this case, the gift card code corresponds to the "ticket secret" in the PDF ticket. Therefore, if selling gift cards,
you can use ticket downloads just as with normal tickets and use our ticket editor to create beautiful gift certificates
people can give to their loved ones.
Of course, you can use pretix' flexible options to modify your product. For example, you can configure that the customer
can freely choose the price of the gift card.
.. note::
pretix currently does not support charging sales tax or VAT when selling gift cards, but instead charges VAT on
the full price when the gift card is redeemed. This is the correct behavior in Germany and some other countries for
gift cards which are not bound to a very specific service ("Warengutschein"), but instead to a monetary amount
("Wertgutschein").
.. note::
The ticket PDF will not contain the correct gift card code before the order has been paid, so we recommend not
selling gift cards in events where tickets are issued before payments arrive.
Accepting gift cards
--------------------
All your events have have the payment provider "Gift card" enabled by default, but it will only show up in the ticket
shop once the very first gift card has been issued on your organizer account. Of course, you can turn off gift card
payments if you do not want them for a specific event.
If gift card payments are enabled, buyers will be able to select "Gift card" as a payment method during checkout. If
a gift card with a value less than the order total is used, the buyer will be asked to select a second payment method
for the remaining payment. If a gift card with a value greater than the order total is used, the surplus amount
remains on the gift card and can be used in a different purchase.
If it possible to accept gift cards across organizer accounts. To do so, you need to have access to both organizer
accounts. Then, you will see a configuration section at the bottom of the "Gift cards" page of your organizer settings
where you can specify which gift cards should be accepted.
Manually issuing or using gift cards
------------------------------------
Of course, you can also issue or redeem gift cards manually through our backend using the "Gift cards" menu item in your
organizer profile or using our API. These gift cards will be tracked by pretix, but do not correspond to any purchase
within pretix. You will therefore need to account for them in your books separately.

View File

@@ -12,5 +12,6 @@ wanting to use pretix to sell tickets.
events/settings events/settings
events/structureguide events/structureguide
events/widget events/widget
events/giftcards
faq faq
markdown markdown

View File

@@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', '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') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self): 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_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until')) 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 return data
def validate_category(self, value): def validate_category(self, value):

View File

@@ -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.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField 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 from pretix.base.models.seating import SeatingPlanLayoutValidator
@@ -18,3 +23,27 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = SeatingPlan model = SeatingPlan
fields = ('id', 'name', 'layout') 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')

View File

@@ -19,6 +19,7 @@ orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
event_router = routers.DefaultRouter() event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'subevents', event.SubEventViewSet)

View File

@@ -1,11 +1,14 @@
from rest_framework import filters, viewsets from django.db import transaction
from rest_framework.exceptions import PermissionDenied 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.models import OAuthAccessToken
from pretix.api.serializers.organizer import ( 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 from pretix.helpers.dicts import merge_dicts
@@ -81,3 +84,66 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
data={'id': instance.pk} data={'id': instance.pk}
) )
instance.delete() 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.")

View 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
),
]

View File

@@ -7,6 +7,7 @@ from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token, RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
) )
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,

View File

@@ -335,6 +335,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True)) | 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): 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) Returns whether or not a user has an active staff session (formerly known as superuser session)

View File

@@ -730,7 +730,7 @@ class Event(EventMixin, LoggedModel):
def has_payment_provider(self): def has_payment_provider(self):
result = False result = False
for provider in self.get_payment_providers().values(): 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 result = True
break break
return result return result

View 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",)

View File

@@ -242,6 +242,8 @@ class Item(LoggedModel):
:type require_approval: bool :type require_approval: bool
:param sales_channels: Sales channels this item is available on. :param sales_channels: Sales channels this item is available on.
:type sales_channels: bool :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() objects = ItemQuerySetManager()
@@ -413,6 +415,12 @@ class Item(LoggedModel):
verbose_name=_('Sales channels'), verbose_name=_('Sales channels'),
default=['web'] 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 # !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable. # pretix/control/forms/item.py if applicable.

View File

@@ -202,7 +202,7 @@ class Order(LockModel, LoggedModel):
return self.full_code return self.full_code
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import Voucher from . import Voucher, GiftCard, GiftCardTransaction
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") raise TypeError("Only test mode orders can be deleted.")
@@ -218,6 +218,10 @@ class Order(LockModel, LoggedModel):
if position.voucher: if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) 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, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete() OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete()
@@ -460,11 +464,15 @@ class Order(LockModel, LoggedModel):
positions = list( positions = list(
self.positions.all().annotate( self.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) 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]) cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
if not cancelable or not positions: if not cancelable or not positions:
return False 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: if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False return False
if self.status == Order.STATUS_PENDING: if self.status == Order.STATUS_PENDING:

View File

@@ -2,6 +2,7 @@ import string
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -82,6 +83,24 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self) 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): def allow_delete(self):
from . import Order, Invoice from . import Order, Invoice
return ( 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 ' 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!') '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( can_change_event_settings = models.BooleanField(
default=False, default=False,

View File

@@ -7,7 +7,9 @@ from typing import Any, Dict, Union
import pytz import pytz
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.db import transaction
from django.dispatch import receiver from django.dispatch import receiver
from django.forms import Form from django.forms import Form
from django.http import HttpRequest from django.http import HttpRequest
@@ -20,8 +22,8 @@ from i18nfield.strings import LazyI18nString
from pretix.base.forms import PlaceholderValidator from pretix.base.forms import PlaceholderValidator
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund, CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
Quota, OrderRefund, Quota,
) )
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
@@ -29,7 +31,8 @@ from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.money import DecimalTextInput 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 from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -888,6 +891,206 @@ class OffsettingProvider(BasePaymentProvider):
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders'])) 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") @receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs): def register_payment_provider(sender, **kwargs):
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment] return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]

View File

@@ -97,6 +97,7 @@ error_messages = {
'seat_forbidden': _('You can not select a seat for this position.'), '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_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.'), '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): 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, for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total): total=total):
if resp: if resp:
fees += resp fees += resp
total = total + sum(f.value for f in fees) 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: if provider and total != 0:
provider = event.get_payment_providers().get(provider) provider = event.get_payment_providers().get(provider)
if provider: if provider:

View File

@@ -20,8 +20,9 @@ from pretix.api.models import OAuthApplication
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle 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.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.signals import ( from pretix.base.signals import (
allow_ticket_download, order_approved, order_canceled, order_changed, allow_ticket_download, order_approved, order_canceled, order_changed,
order_denied, order_expired, order_fee_calculation, order_placed, order_denied, order_expired, order_fee_calculation, order_paid,
order_split, periodic_task, validate_order, order_placed, order_split, periodic_task, validate_order,
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.models import modelcopy 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: if i:
generate_cancellation(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: if cancellation_fee:
with order.event.lock(): with order.event.lock():
for position in order.positions.all(): 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, 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 = [] fees = []
total = sum([c.price for c in positions]) 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): meta_info=meta_info, positions=positions):
if resp: if resp:
fees += resp fees += resp
total += sum(f.value for f in fees) 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: if payment_provider:
payment_fee = payment_provider.calculate_fee(total) payment_fee = payment_provider.calculate_fee(total)
else: else:
@@ -565,17 +587,34 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
internal_type=payment_provider.identifier) internal_type=payment_provider.identifier)
fees.append(pf) 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, def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None, sales_channel: str='web'): meta_info: dict=None, sales_channel: str='web', gift_cards: list=None,
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event) shown_total=None):
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
p = None p = None
with transaction.atomic(): 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( order = Order(
status=Order.STATUS_PENDING, status=Order.STATUS_PENDING,
event=event, 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.tax_rule = None # TODO: deprecate
fee.save() 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: if payment_provider and not order.require_approval:
p = order.payments.create( p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED, state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier, provider=payment_provider.identifier,
amount=total, amount=pending_sum,
fee=pf 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], 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: if payment_provider:
pprov = event.get_payment_providers().get(payment_provider) pprov = event.get_payment_providers().get(payment_provider)
if not pprov: if not pprov:
@@ -707,9 +777,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr) _check_positions(event, now_dt, positions, address=addr)
order, payment = _create_order(event, email, positions, now_dt, pprov, 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: if free_order_flow:
try: try:
payment.confirm(send_mail=False, lock=not locked) 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_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_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow 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')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
@@ -961,6 +1033,9 @@ class OrderChangeManager:
def change_price(self, position: OrderPosition, price: Decimal): def change_price(self, position: OrderPosition, price: Decimal):
price = position.item.tax(price, base_price_is='gross') 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 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'): 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._calculate_tax()
op.position.save() op.position.save()
elif isinstance(op, self.CancelOperation): 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(): for opa in op.position.addons.all():
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': opa.pk, 'position': opa.pk,
@@ -1466,12 +1552,12 @@ class OrderChangeManager:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @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], 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, 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): with language(locale):
try: try:
try: try:
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
sales_channel) sales_channel, gift_cards, shown_total)
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
@@ -1615,3 +1701,25 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
generate_invoice(order) generate_invoice(order)
return old_fee, new_fee, fee, new_payment 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})

View File

@@ -133,6 +133,10 @@ DEFAULTS = {
'default': 'True', 'default': 'True',
'type': bool 'type': bool
}, },
'payment_giftcard__enabled': {
'default': 'True',
'type': bool
},
'payment_term_accept_late': { 'payment_term_accept_late': {
'default': 'True', 'default': 'True',
'type': bool 'type': bool

View File

@@ -502,6 +502,29 @@ class OrganizerFilterForm(FilterForm):
return qs 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): class EventFilterForm(FilterForm):
orders = { orders = {
'slug': 'slug', 'slug': 'slug',

View File

@@ -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'].widget.choices = self.fields['hidden_if_available'].choices
self.fields['hidden_if_available'].required = False 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: class Meta:
model = Item model = Item
localized_fields = '__all__' localized_fields = '__all__'
@@ -451,6 +468,7 @@ class ItemUpdateForm(I18nModelForm):
'require_bundling', 'require_bundling',
'show_quota_left', 'show_quota_left',
'hidden_if_available', 'hidden_if_available',
'issue_giftcard',
] ]
field_classes = { field_classes = {
'available_from': SplitDateTimeField, 'available_from': SplitDateTimeField,

View File

@@ -4,6 +4,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.db.models import Q
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField 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.models import WebHook
from pretix.api.webhooks import get_all_webhook_events from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm 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 ( from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, ExtFileField, FontSelect, MultipleLanguagesWidget,
) )
@@ -145,6 +146,7 @@ class TeamForm(forms.ModelForm):
model = Team model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events', fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings', 'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards',
'can_change_event_settings', 'can_change_items', 'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_view_orders', 'can_change_orders',
'can_view_vouchers', 'can_change_vouchers'] 'can_view_vouchers', 'can_change_vouchers']
@@ -328,3 +330,29 @@ class WebHookForm(forms.ModelForm):
field_classes = { field_classes = {
'limit_events': SafeModelMultipleChoiceField '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']

View File

@@ -437,6 +437,16 @@ def get_organizer_navigation(request):
'active': 'organizer.device' in url.url_name, 'active': 'organizer.device' in url.url_name,
'icon': 'tablet', '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({ nav.append({
'label': _('Webhooks'), 'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={ 'url': reverse('control:organizer.webhooks', kwargs={

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,7 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Additional settings" %}</legend> <legend>{% trans "Additional settings" %}</legend>
{% bootstrap_field form.issue_giftcard layout="control" %}
{% bootstrap_field form.show_quota_left layout="control" %} {% bootstrap_field form.show_quota_left layout="control" %}
{% for f in plugin_forms %} {% for f in plugin_forms %}
{% bootstrap_form f layout="control" %} {% bootstrap_form f layout="control" %}

View File

@@ -310,6 +310,14 @@
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %} {% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
</div> </div>
{% endif %} {% 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 %} {% if line.has_questions %}
<dl> <dl>
{% if line.item.admission and event.settings.attendee_names_asked %} {% if line.item.admission and event.settings.attendee_names_asked %}

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@
<fieldset> <fieldset>
<legend>{% trans "Organizer permissions" %}</legend> <legend>{% trans "Organizer permissions" %}</legend>
{% bootstrap_field form.can_create_events layout="control" %} {% 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_teams layout="control" %}
{% bootstrap_field form.can_change_organizer_settings layout="control" %} {% bootstrap_field form.can_change_organizer_settings layout="control" %}
</fieldset> </fieldset>

View File

@@ -75,6 +75,9 @@ urlpatterns = [
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'), url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
name='organizer.display'), 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>[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
url(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(), url(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
name='organizer.webhook.add'), name='organizer.webhook.add'),

View File

@@ -237,7 +237,7 @@ class OrderDetail(OrderView):
).select_related( ).select_related(
'item', 'variation', 'addon_to', 'tax_rule' 'item', 'variation', 'addon_to', 'tax_rule'
).prefetch_related( ).prefetch_related(
'item__questions', 'item__questions', 'issued_gift_cards',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
'checkins', 'checkins__list' 'checkins', 'checkins__list'
).order_by('positionid') ).order_by('positionid')
@@ -897,23 +897,26 @@ class OrderTransition(OrderView):
else: else:
messages.success(self.request, _('The payment has been created successfully.')) 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(): elif self.order.cancel_allowed() and to == 'c' and self.mark_canceled_form.is_valid():
cancel_order(self.order, user=self.request.user, try:
send_mail=self.mark_canceled_form.cleaned_data['send_email'], cancel_order(self.order, user=self.request.user,
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee')) send_mail=self.mark_canceled_form.cleaned_data['send_email'],
self.order.refresh_from_db() 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.'))
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.'))
elif self.order.status == Order.STATUS_PENDING and to == 'e': elif self.order.status == Order.STATUS_PENDING and to == 'e':
mark_order_expired(self.order, user=self.request.user) mark_order_expired(self.order, user=self.request.user)
messages.success(self.request, _('The order has been marked as expired.')) messages.success(self.request, _('The order has been marked as expired.'))

View File

@@ -1,12 +1,13 @@
import json import json
from decimal import Decimal
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages 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.core.files import File
from django.db import transaction 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.db.models.functions import Coalesce, Greatest
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.http import JsonResponse from django.http import JsonResponse
@@ -21,14 +22,17 @@ from django.views.generic import (
from pretix.api.models import WebHook from pretix.api.models import WebHook
from pretix.base.auth import get_auth_backends 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.event import Event, EventMetaProperty
from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.organizer import TeamAPIToken
from pretix.base.services.mail import SendMailException, mail 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 ( from pretix.control.forms.organizer import (
DeviceForm, EventMetaPropertyForm, OrganizerDeleteForm, OrganizerForm, DeviceForm, EventMetaPropertyForm, GiftCardCreateForm, OrganizerDeleteForm,
OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
WebHookForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
@@ -337,7 +341,7 @@ class OrganizerCreate(CreateView):
ret = super().form_valid(form) ret = super().form_valid(form)
t = Team.objects.create( t = Team.objects.create(
organizer=form.instance, name=_('Administrators'), 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_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 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): def get_queryset(self):
return self.webhook.calls.order_by('-datetime') 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
}
))

View File

@@ -632,6 +632,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
ctx['cart_session'] = self.cart_session ctx['cart_session'] = self.cart_session
ctx['invoice_address_asked'] = self.address_asked 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') email = self.cart_session.get('contact_form_data', {}).get('email')
if email != settings.PRETIX_EMAIL_NONE_VALUE: if email != settings.PRETIX_EMAIL_NONE_VALUE:
ctx['contact_info'] = [ 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, 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'), [p.id for p in self.positions], self.cart_session.get('email'),
translation.get_language(), self.invoice_address.pk, meta_info, 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): def get_success_message(self, value):
create_empty_cart_id(self.request) create_empty_cart_id(self.request)

View File

@@ -17,19 +17,19 @@
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <h4 class="panel-title">
{% if show_fees %} {% 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 %} {% endif %}
<input type="radio" name="payment" value="{{ p.provider.identifier }}" <input type="radio" name="payment" value="{{ p.provider.identifier }}"
title="{{ p.provider.public_name }}" title="{{ p.provider.public_name }}"
data-parent="#payment_accordion" data-parent="#payment_accordion"
{% if selected == p.provider.identifier %}checked="checked"{% endif %} {% if selected == p.provider.identifier %}checked="checked"{% endif %}
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/> data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/>
<strong>{{ p.provider.public_name }}</strong> <strong>{{ p.provider.public_name }}</strong>
</h4> </h4>
</div> </div>
</label> </label>
<div id="payment_{{ p.provider.identifier }}" <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"> <div class="panel-body form-horizontal">
{% if request.event.testmode %} {% if request.event.testmode %}
{% if p.provider.test_mode_message %} {% if p.provider.test_mode_message %}

View File

@@ -33,7 +33,14 @@
{% endif %} {% endif %}
{% 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 %} {% if line.has_questions %}
<dl> <dl>
{% if line.item.admission and event.settings.attendee_names_asked %} {% if line.item.admission and event.settings.attendee_names_asked %}

View File

@@ -128,6 +128,26 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event download=can_download editable=False %} {% 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>
</div> </div>
{% eventsignal event "pretix.presale.signals.order_info" order=order %} {% eventsignal event "pretix.presale.signals.order_info" order=order %}

View File

@@ -478,7 +478,10 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView):
if v_avail < 1 and not err: if v_avail < 1 and not err:
err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time
except Voucher.DoesNotExist: 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: else:
return redirect(self.get_index_url()) return redirect(self.get_index_url())

View File

@@ -163,7 +163,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart( ctx['cart'] = self.get_cart(
answers=True, downloads=ctx['can_download'], 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 order=self.order
) )
ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and ( 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: if self.order.status == Order.STATUS_PENDING:
ctx['pending_sum'] = self.order.pending_sum ctx['pending_sum'] = self.order.pending_sum
ctx['payment_sum_neg'] = ctx['pending_sum'] - self.order.total
lp = self.order.payments.last() lp = self.order.payments.last()
ctx['can_pay'] = False ctx['can_pay'] = False

View File

@@ -241,6 +241,7 @@ ENTROPY = {
'order_code': config.getint('entropy', 'order_code', fallback=5), 'order_code': config.getint('entropy', 'order_code', fallback=5),
'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32), 'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32),
'voucher_code': config.getint('entropy', 'voucher_code', fallback=16), 'voucher_code': config.getint('entropy', 'voucher_code', fallback=16),
'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12),
} }
# Internal settings # Internal settings

View File

@@ -70,6 +70,7 @@ def event3(organizer, meta_prop):
def team(organizer): def team(organizer):
return Team.objects.create( return Team.objects.create(
organizer=organizer, organizer=organizer,
can_manage_gift_cards=True,
can_change_items=True, can_change_items=True,
can_create_events=True, can_create_events=True,
can_change_event_settings=True, can_change_event_settings=True,
@@ -155,6 +156,12 @@ def taxrule(event):
return event.tax_rules.create(name="VAT", rate=19) return event.tax_rules.create(name="VAT", rate=19)
@pytest.fixture
@scopes_disabled()
def taxrule0(event):
return event.tax_rules.create(name="VAT", rate=0)
@pytest.fixture @pytest.fixture
@scopes_disabled() @scopes_disabled()
def taxrule2(event2): def taxrule2(event2):

View File

@@ -0,0 +1,121 @@
import copy
from decimal import Decimal
import pytest
from django_scopes import scopes_disabled
from pretix.base.models import GiftCard
@pytest.fixture
def giftcard(organizer, event):
gc = organizer.issued_gift_cards.create(secret="ABCDEF", currency="EUR")
gc.transactions.create(value=Decimal('23.00'))
return gc
TEST_GC_RES = {
"id": 1,
"secret": "ABCDEF",
"value": "23.00",
"testmode": False,
"currency": "EUR"
}
@pytest.mark.django_db
def test_giftcard_list(token_client, organizer, event, giftcard):
res = dict(TEST_GC_RES)
res["id"] = giftcard.pk
res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/giftcards/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_giftcard_detail(token_client, organizer, event, giftcard):
res = dict(TEST_GC_RES)
res["id"] = giftcard.pk
res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk))
assert resp.status_code == 200
assert res == resp.data
TEST_GIFTCARD_CREATE_PAYLOAD = {
"secret": "DEFABC",
"value": "12.00",
"testmode": False,
"currency": "EUR",
}
@pytest.mark.django_db
def test_giftcard_create(token_client, organizer, event):
resp = token_client.post(
'/api/v1/organizers/{}/giftcards/'.format(organizer.slug),
TEST_GIFTCARD_CREATE_PAYLOAD,
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
gc = GiftCard.objects.get(pk=resp.data['id'])
assert gc.issuer == organizer
assert gc.value == Decimal('12.00')
@pytest.mark.django_db
def test_giftcard_duplicate_secert(token_client, organizer, event, giftcard):
res = copy.copy(TEST_GIFTCARD_CREATE_PAYLOAD)
res['secret'] = 'ABCDEF'
resp = token_client.post(
'/api/v1/organizers/{}/giftcards/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
assert resp.data == {'secret': ['A gift card with the same secret already exists in your or an affiliated organizer account.']}
@pytest.mark.django_db
def test_giftcard_patch(token_client, organizer, event, giftcard):
resp = token_client.patch(
'/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk),
{
'secret': 'foo',
'value': '10.00',
'testmode': True,
'currency': 'USD'
},
format='json'
)
assert resp.status_code == 200
giftcard.refresh_from_db()
assert giftcard.value == Decimal('10.00')
assert giftcard.secret == "ABCDEF"
assert giftcard.currency == "EUR"
assert not giftcard.testmode
@pytest.mark.django_db
def test_giftcard_transact(token_client, organizer, event, giftcard):
resp = token_client.post(
'/api/v1/organizers/{}/giftcards/{}/transact/'.format(organizer.slug, giftcard.pk),
{
'value': '10.00',
},
format='json'
)
assert resp.status_code == 200
giftcard.refresh_from_db()
assert giftcard.value == Decimal('33.00')
@pytest.mark.django_db
def test_giftcard_no_deletion(token_client, organizer, event, giftcard):
resp = token_client.delete(
'/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk),
)
assert resp.status_code == 405

View File

@@ -223,6 +223,7 @@ TEST_ITEM_RES = {
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": None, "tax_rule": None,
"admission": False, "admission": False,
"issue_giftcard": False,
"position": 0, "position": 0,
"generate_tickets": None, "generate_tickets": None,
"allow_waitinglist": True, "allow_waitinglist": True,
@@ -391,6 +392,7 @@ def test_item_create(token_client, organizer, event, item, category, taxrule):
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -426,6 +428,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -460,6 +463,75 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego
assert new_item.variations.first().value.localize('en') == "Comment" assert new_item.variations.first().value.localize('en') == "Comment"
@pytest.mark.django_db
def test_item_create_giftcard_validation(token_client, organizer, event, item, category, category2, taxrule, taxrule0):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug),
{
"category": category.pk,
"name": {
"en": "Ticket"
},
"active": True,
"description": None,
"default_price": "23.00",
"free_price": False,
"tax_rate": "19.00",
"tax_rule": taxrule0.pk,
"admission": True,
"issue_giftcard": True,
"position": 0,
"picture": None,
"available_from": None,
"available_until": None,
"require_voucher": False,
"hide_without_voucher": False,
"allow_cancel": True,
"min_per_order": None,
"max_per_order": None,
"checkin_attention": False,
"has_variations": True,
"addons": []
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Gift card products should not be admission products at the same time."]}'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug),
{
"category": category.pk,
"name": {
"en": "Ticket"
},
"active": True,
"description": None,
"default_price": "23.00",
"free_price": False,
"tax_rate": "19.00",
"tax_rule": taxrule.pk,
"admission": False,
"issue_giftcard": True,
"position": 0,
"picture": None,
"available_from": None,
"available_until": None,
"require_voucher": False,
"hide_without_voucher": False,
"allow_cancel": True,
"min_per_order": None,
"max_per_order": None,
"checkin_attention": False,
"has_variations": True,
"addons": []
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Gift card products should not be associated with non-zero ' \
'tax rates since sales tax will be applied when the gift card is redeemed."]}'
@pytest.mark.django_db @pytest.mark.django_db
def test_item_create_with_addon(token_client, organizer, event, item, category, category2, taxrule): def test_item_create_with_addon(token_client, organizer, event, item, category, category2, taxrule):
resp = token_client.post( resp = token_client.post(
@@ -476,6 +548,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -520,6 +593,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -562,6 +636,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -604,6 +679,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": True,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -654,6 +730,7 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,
@@ -699,6 +776,7 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category,
"tax_rate": "19.00", "tax_rate": "19.00",
"tax_rule": taxrule.pk, "tax_rule": taxrule.pk,
"admission": True, "admission": True,
"issue_giftcard": False,
"position": 0, "position": 0,
"picture": None, "picture": None,
"available_from": None, "available_from": None,

View File

@@ -137,6 +137,11 @@ org_permission_sub_urls = [
('put', 'can_change_organizer_settings', 'webhooks/1/', 404), ('put', 'can_change_organizer_settings', 'webhooks/1/', 404),
('patch', 'can_change_organizer_settings', 'webhooks/1/', 404), ('patch', 'can_change_organizer_settings', 'webhooks/1/', 404),
('delete', 'can_change_organizer_settings', 'webhooks/1/', 404), ('delete', 'can_change_organizer_settings', 'webhooks/1/', 404),
('get', 'can_manage_gift_cards', 'giftcards/', 200),
('post', 'can_manage_gift_cards', 'giftcards/', 400),
('get', 'can_manage_gift_cards', 'giftcards/1/', 404),
('put', 'can_manage_gift_cards', 'giftcards/1/', 404),
('patch', 'can_manage_gift_cards', 'giftcards/1/', 404),
] ]

View File

@@ -1074,6 +1074,17 @@ class OrderTestCase(BaseQuotaTestCase):
self.event.settings.cancel_allow_user = False self.event.settings.cancel_allow_user = False
assert not self.order.user_cancel_allowed assert not self.order.user_cancel_allowed
@classscope(attr='o')
def test_can_cancel_order_with_giftcard(self):
item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23,
admission=True, allow_cancel=True, issue_giftcard=True)
p = OrderPosition.objects.create(order=self.order, item=item1,
variation=None, price=23)
self.event.organizer.issued_gift_cards.create(
currency="EUR", issued_in=p
)
assert not self.order.user_cancel_allowed
@classscope(attr='o') @classscope(attr='o')
def test_can_cancel_order_free(self): def test_can_cancel_order_free(self):
self.order.status = Order.STATUS_PAID self.order.status = Order.STATUS_PAID

View File

@@ -23,6 +23,7 @@ from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, approve_order, cancel_order, OrderChangeManager, OrderError, _create_order, approve_order, cancel_order,
deny_order, expire_orders, send_download_reminders, send_expiry_warnings, deny_order, expire_orders, send_download_reminders, send_expiry_warnings,
) )
from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.testutils.scope import classscope from pretix.testutils.scope import classscope
@@ -592,6 +593,52 @@ class OrderCancelTests(TestCase):
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
@classscope(attr='o')
def test_auto_refund_possible_giftcard(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
p1 = self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='giftcard',
info='{"gift_card": %d}' % gc.pk
)
cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert r.amount == Decimal('44.00')
assert r.source == OrderRefund.REFUND_SOURCE_BUYER
assert r.payment == p1
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists()
assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
assert gc.value == Decimal('44.00')
@classscope(attr='o')
def test_auto_refund_possible_issued_giftcard(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
gc.transactions.create(value=23)
self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='testdummy_partialrefund'
)
cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
assert gc.value == Decimal('0.00')
@classscope(attr='o')
def test_auto_refund_impossible_issued_giftcard_used(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
gc.transactions.create(value=20)
self.order.payments.create(
amount=Decimal('46.00'),
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
provider='testdummy_partialrefund'
)
with pytest.raises(OrderError):
cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True)
assert gc.value == Decimal('20.00')
@classscope(attr='o') @classscope(attr='o')
def test_auto_refund_impossible(self): def test_auto_refund_impossible(self):
self.order.payments.create( self.order.payments.create(
@@ -859,6 +906,29 @@ class OrderChangeManagerTests(TestCase):
assert self.op1.price == Decimal('24.00') assert self.op1.price == Decimal('24.00')
assert self.order.status == Order.STATUS_PENDING assert self.order.status == Order.STATUS_PENDING
@classscope(attr='o')
def test_cancel_issued_giftcard(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
gc.transactions.create(value=23)
self.ocm.cancel(self.op1)
self.ocm.commit()
assert gc.value == Decimal('0.00')
@classscope(attr='o')
def test_cancel_issued_giftcard_used(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
gc.transactions.create(value=20)
self.ocm.cancel(self.op1)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_price_issued_giftcard_used(self):
gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1)
gc.transactions.create(value=20)
with self.assertRaises(OrderError):
self.ocm.change_price(self.op1, 25)
@classscope(attr='o') @classscope(attr='o')
def test_cancel_all_in_order(self): def test_cancel_all_in_order(self):
self.ocm.cancel(self.op1) self.ocm.cancel(self.op1)
@@ -1888,3 +1958,174 @@ def test_autocheckin(clist_autocheckin, event):
locale='de')[0] locale='de')[0]
assert clist_autocheckin.auto_checkin_sales_channels == [] assert clist_autocheckin.auto_checkin_sales_channels == []
assert order.positions.first().checkins.count() == 0 assert order.positions.first().checkins.count() == 0
@pytest.mark.django_db
def test_giftcard_multiple(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="EUR")
gc1.transactions.create(value=12)
gc2 = event.organizer.issued_gift_cards.create(currency="EUR")
gc2.transactions.create(value=12)
order = _create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk, gc2.pk])[0]
assert order.payments.count() == 3
assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00')
assert order.payments.get(info__icontains=gc2.pk).amount == Decimal('11.00')
assert gc1.value == 0
assert gc2.value == 1
@pytest.mark.django_db
def test_giftcard_partial(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="EUR")
gc1.transactions.create(value=12)
order = _create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
assert order.payments.count() == 2
assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00')
assert order.payments.get(provider='banktransfer').amount == Decimal('11.00')
assert gc1.value == 0
@pytest.mark.django_db
def test_giftcard_payment_fee(event):
event.settings.set('payment_banktransfer__fee_percent', Decimal('10.00'))
event.settings.set('payment_banktransfer__fee_reverse_calc', False)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="EUR")
gc1.transactions.create(value=12)
order = _create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
assert order.payments.count() == 2
assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00')
assert order.payments.get(provider='banktransfer').amount == Decimal('12.10')
assert order.fees.get().value == Decimal('1.10')
assert gc1.value == 0
@pytest.mark.django_db
def test_giftcard_invalid_currency(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="USD")
gc1.transactions.create(value=12)
with pytest.raises(OrderError):
_create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
@pytest.mark.django_db
def test_giftcard_invalid_organizer(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
o2 = Organizer.objects.create(slug="foo", name="bar")
gc1 = o2.issued_gift_cards.create(currency="EUR")
gc1.transactions.create(value=12)
with pytest.raises(OrderError):
_create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
@pytest.mark.django_db
def test_giftcard_test_mode_invalid(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="EUR", testmode=True)
gc1.transactions.create(value=12)
with pytest.raises(OrderError):
_create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
@pytest.mark.django_db
def test_giftcard_test_mode_event(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
event.testmode = True
event.save()
gc1 = event.organizer.issued_gift_cards.create(currency="EUR", testmode=False)
gc1.transactions.create(value=12)
with pytest.raises(OrderError):
_create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
@pytest.mark.django_db
def test_giftcard_swap(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket', issue_giftcard=True,
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
gc1 = event.organizer.issued_gift_cards.create(currency="EUR", testmode=False)
gc1.transactions.create(value=12)
with pytest.raises(OrderError):
_create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[gc1.pk])[0]
@pytest.mark.django_db
def test_issue_when_paid_and_changed(event):
ticket = Item.objects.create(event=event, name='Early-bird ticket', issue_giftcard=True,
default_price=Decimal('23.00'), admission=True)
cp1 = CartPosition.objects.create(
item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123"
)
q = event.quotas.create(size=None, name="foo")
q.items.add(ticket)
order = _create_order(event, email='dummy@example.org', positions=[cp1],
now_dt=now(), payment_provider=BankTransfer(event),
locale='de', gift_cards=[])[0]
op = order.positions.first()
assert not op.issued_gift_cards.exists()
order.payments.first().confirm()
gc1 = op.issued_gift_cards.get()
assert gc1.value == op.price
op.refresh_from_db()
assert op.secret == gc1.secret
ocm = OrderChangeManager(order)
ocm.add_position(ticket, None, Decimal('12.00'))
ocm.commit()
order.payments.create(
provider='manual', amount=order.pending_sum
).confirm()
assert op.issued_gift_cards.count() == 1
op2 = order.positions.last()
gc2 = op2.issued_gift_cards.get()
assert gc2.value == op2.price

View File

@@ -0,0 +1,125 @@
import pytest
from pretix.base.models import Organizer, Team, User
@pytest.fixture
def organizer():
return Organizer.objects.create(name='Dummy', slug='dummy')
@pytest.fixture
def organizer2():
return Organizer.objects.create(name='Partner', slug='partner')
@pytest.fixture
def gift_card(organizer):
gc = organizer.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=42)
return gc
@pytest.fixture
def admin_user(organizer):
u = User.objects.create_user('dummy@dummy.dummy', 'dummy')
admin_team = Team.objects.create(organizer=organizer, can_manage_gift_cards=True, name='Admin team')
admin_team.members.add(u)
return u
@pytest.fixture
def team2(admin_user, organizer2):
admin_team = Team.objects.create(organizer=organizer2, can_manage_gift_cards=True, name='Admin team')
admin_team.members.add(admin_user)
@pytest.mark.django_db
def test_list_of_cards(organizer, admin_user, client, gift_card):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.get('/control/organizer/dummy/giftcards')
assert gift_card.secret in resp.content.decode()
resp = client.get('/control/organizer/dummy/giftcards?query=' + gift_card.secret[:3])
assert gift_card.secret in resp.content.decode()
resp = client.get('/control/organizer/dummy/giftcards?query=1234_FOO')
assert gift_card.secret not in resp.content.decode()
@pytest.mark.django_db
def test_card_detail_view(organizer, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.get('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk))
assert gift_card.secret in resp.content.decode()
assert '42.00' in resp.content.decode()
@pytest.mark.django_db
def test_card_add(organizer, admin_user, client):
client.login(email='dummy@dummy.dummy', password='dummy')
resp = client.post('/control/organizer/dummy/giftcard/add', {
'currency': 'EUR',
'secret': 'FOOBAR',
'value': '42.00',
'testmode': 'on'
}, follow=True)
assert 'TEST MODE' in resp.content.decode()
assert '42.00' in resp.content.decode()
resp = client.post('/control/organizer/dummy/giftcard/add', {
'currency': 'EUR',
'secret': 'FOOBAR',
'value': '42.00',
'testmode': 'on'
}, follow=True)
assert 'has-error' in resp.content.decode()
@pytest.mark.django_db
def test_card_detail_view_transact(organizer, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), {
'value': '23.00'
})
assert gift_card.value == 23 + 42
assert gift_card.all_logentries().count() == 1
@pytest.mark.django_db
def test_card_detail_view_transact_min_value(organizer, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
r = client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), {
'value': '-50.00'
})
assert 'alert-danger' in r.rendered_content
assert gift_card.value == 42
@pytest.mark.django_db
def test_card_detail_view_transact_invalid_value(organizer, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
r = client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), {
'value': 'foo'
})
assert 'alert-danger' in r.rendered_content
assert gift_card.value == 42
@pytest.mark.django_db
def test_manage_acceptance(organizer, organizer2, admin_user, gift_card, client, team2):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), {
'add': organizer2.slug
})
assert organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), {
'del': organizer2.slug
})
assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
@pytest.mark.django_db
def test_manage_acceptance_permission_required(organizer, organizer2, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), {
'add': organizer2.slug
})
assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()

View File

@@ -419,6 +419,22 @@ class ItemsTest(ItemFormTest):
self.item1.refresh_from_db() self.item1.refresh_from_db()
assert self.item1.default_price == Decimal('23.00') assert self.item1.default_price == Decimal('23.00')
def test_update_validate_giftcard(self):
doc = self.get_doc('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item2.id))
d = extract_form_fields(doc.select('.container-fluid form')[0])
d.update({
'name_0': 'Standard',
'default_price': '23.00',
'admission': 'on',
'issue_giftcard': 'on',
'active': 'yes',
'allow_cancel': 'yes',
'sales_channels': 'web'
})
self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), d)
self.item1.refresh_from_db()
assert not self.item1.issue_giftcard
def test_manipulate_addons(self): def test_manipulate_addons(self):
doc = self.get_doc('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item2.id)) doc = self.get_doc('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item2.id))
d = extract_form_fields(doc.select('.container-fluid form')[0]) d = extract_form_fields(doc.select('.container-fluid form')[0])

View File

@@ -141,6 +141,9 @@ organizer_urls = [
'organizer/abc/webhook/add', 'organizer/abc/webhook/add',
'organizer/abc/webhook/1/edit', 'organizer/abc/webhook/1/edit',
'organizer/abc/webhook/1/logs', 'organizer/abc/webhook/1/logs',
'organizer/abc/giftcards',
'organizer/abc/giftcard/add',
'organizer/abc/giftcard/1/',
] ]
@@ -390,10 +393,9 @@ organizer_permission_urls = [
("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404),
("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404),
("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404), ("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404),
("can_change_organizer_settings", "organizer/dummy/webhooks", 200), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200),
("can_change_organizer_settings", "organizer/dummy/webhook/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200),
("can_change_organizer_settings", "organizer/dummy/webhook/1/edit", 404), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404),
("can_change_organizer_settings", "organizer/dummy/webhook/1/logs", 404),
] ]

View File

@@ -102,6 +102,7 @@ def logged_in_client(client, event):
('/control/organizer/{orga}/teams', 200), ('/control/organizer/{orga}/teams', 200),
('/control/organizer/{orga}/devices', 200), ('/control/organizer/{orga}/devices', 200),
('/control/organizer/{orga}/webhooks', 200), ('/control/organizer/{orga}/webhooks', 200),
('/control/organizer/{orga}/giftcards', 200),
('/control/events/', 200), ('/control/events/', 200),
('/control/events/add', 200), ('/control/events/add', 200),

View File

@@ -795,6 +795,266 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select(".alert-danger") assert doc.select(".alert-danger")
def test_giftcard_partial(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_stripe__enabled', True)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('input[name="payment"]')), 3)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€20.00' in response.rendered_content
assert '3.00' in response.rendered_content
assert 'alert-success' in response.rendered_content
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€20.00' in response.rendered_content
assert '3.00' in response.rendered_content
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert o.payments.get(provider='giftcard').amount == Decimal('20.00')
assert o.payments.get(provider='banktransfer').amount == Decimal('3.00')
assert '-€20.00' in response.rendered_content
assert '3.00' in response.rendered_content
def test_giftcard_full(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=30)
self.event.settings.set('payment_stripe__enabled', True)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('input[name="payment"]')), 3)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€23.00' in response.rendered_content
assert '0.00' in response.rendered_content
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert o.payments.get(provider='giftcard').amount == Decimal('23.00')
def test_giftcard_racecondition(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_stripe__enabled', True)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select('input[name="payment"]')), 3)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€20.00' in response.rendered_content
assert '3.00' in response.rendered_content
assert 'alert-success' in response.rendered_content
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€20.00' in response.rendered_content
assert '3.00' in response.rendered_content
gc.transactions.create(value=-2)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".alert-danger")), 1)
assert '-€18.00' in response.rendered_content
assert '5.00' in response.rendered_content
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert o.payments.get(provider='giftcard').amount == Decimal('18.00')
assert o.payments.get(provider='banktransfer').amount == Decimal('5.00')
def test_giftcard_invalid_currency(self):
gc = self.orga.issued_gift_cards.create(currency="USD")
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'This gift card does not support this currency.' in response.rendered_content
def test_giftcard_invalid_organizer(self):
self.orga.issued_gift_cards.create(currency="EUR")
orga2 = Organizer.objects.create(slug="foo2", name="foo2")
gc = orga2.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'This gift card is not known.' in response.rendered_content
def test_giftcard_cross_organizer(self):
self.orga.issued_gift_cards.create(currency="EUR")
orga2 = Organizer.objects.create(slug="foo2", name="foo2")
gc = orga2.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=23)
self.orga.gift_card_issuer_acceptance.create(issuer=orga2)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert '-€23.00' in response.rendered_content
assert '0.00' in response.rendered_content
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert o.payments.get(provider='giftcard').amount == Decimal('23.00')
def test_giftcard_in_test_mode(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.testmode = True
self.event.save()
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'Only test gift cards can be used in test mode.' in response.rendered_content
def test_giftcard_not_in_test_mode(self):
gc = self.orga.issued_gift_cards.create(currency="EUR", testmode=True)
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'This gift card can only be used in test mode.' in response.rendered_content
def test_giftcard_empty(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'All credit on this gift card has been used.' in response.rendered_content
def test_giftcard_twice(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'This gift card is already used for your payment.' in response.rendered_content
def test_giftcard_swap(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20)
self.event.settings.set('payment_banktransfer__enabled', True)
self.ticket.issue_giftcard = True
self.ticket.save()
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'You cannot pay with gift cards when buying a gift card.' in response.rendered_content
def test_premature_confirm(self): def test_premature_confirm(self):
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
@@ -1379,6 +1639,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr1.voucher = v cr1.voucher = v
cr1.save() cr1.save()
self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml") doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1) self.assertEqual(len(doc.select(".thank-you")), 1)

View File

@@ -874,7 +874,7 @@ class OrdersTest(BaseOrdersTest):
) )
assert 'Test dummy' in response.rendered_content assert 'Test dummy' in response.rendered_content
assert '+ €12.00' in response.rendered_content assert '+ €12.00' in response.rendered_content
response = self.client.post( self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), '/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{ {
'payment': 'testdummy' 'payment': 'testdummy'
@@ -893,6 +893,171 @@ class OrdersTest(BaseOrdersTest):
assert self.order.total == Decimal('23.00') + fee.value assert self.order.total == Decimal('23.00') + fee.value
assert self.order.invoices.count() == 3 assert self.order.invoices.count() == 3
def test_change_paymentmethod_giftcard_partial(self):
with scopes_disabled():
self.order.payments.create(
provider='manual',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=Decimal('10.00'),
)
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=10)
response = self.client.get(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
)
assert 'Gift card' in response.rendered_content
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
with scopes_disabled():
p = self.order.payments.last()
self.assertRedirects(
response,
'/%s/%s/order/%s/%s/pay/%s/confirm' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret, p.pk),
)
self.client.post(
'/%s/%s/order/%s/%s/pay/%s/confirm' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret, p.pk),
{}
)
self.order.refresh_from_db()
p.refresh_from_db()
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert self.order.status == Order.STATUS_PENDING
assert gc.value == Decimal('0.00')
assert self.order.pending_sum == Decimal('3.00')
def test_change_paymentmethod_giftcard_swap_card(self):
with scopes_disabled():
self.order.payments.create(
provider='manual',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=Decimal('10.00'),
)
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=10)
self.ticket.issue_giftcard = True
self.ticket.save()
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "You cannot pay with gift cards when buying a gift card." in response.rendered_content
def test_change_paymentmethod_giftcard_wrong_currency(self):
with scopes_disabled():
gc = self.orga.issued_gift_cards.create(currency="USD")
gc.transactions.create(value=10)
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "This gift card does not support this currency." in response.rendered_content
def test_change_paymentmethod_giftcard_in_test_mode(self):
with scopes_disabled():
self.order.testmode = True
self.order.save()
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=10)
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "Only test gift cards can be used in test mode." in response.rendered_content
def test_change_paymentmethod_giftcard_not_in_test_mode(self):
with scopes_disabled():
gc = self.orga.issued_gift_cards.create(currency="EUR", testmode=True)
gc.transactions.create(value=10)
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "This gift card can only be used in test mode." in response.rendered_content
def test_change_paymentmethod_giftcard_empty(self):
with scopes_disabled():
gc = self.orga.issued_gift_cards.create(currency="EUR")
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "All credit on this gift card has been used." in response.rendered_content
def test_change_paymentmethod_giftcard_wrong_organizer(self):
with scopes_disabled():
o = Organizer.objects.create(slug='Foo', name='bar')
self.orga.issued_gift_cards.create(currency="EUR")
gc = o.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=10)
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
assert "This gift card is not known." in response.rendered_content
def test_change_paymentmethod_giftcard(self):
with scopes_disabled():
self.order.payments.create(
provider='manual',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=Decimal('10.00'),
)
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=100)
response = self.client.get(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
)
assert 'Gift card' in response.rendered_content
response = self.client.post(
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{
'payment': 'giftcard',
'giftcard': gc.secret
}
)
with scopes_disabled():
p = self.order.payments.last()
self.assertRedirects(
response,
'/%s/%s/order/%s/%s/pay/%s/confirm' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret, p.pk),
)
self.client.post(
'/%s/%s/order/%s/%s/pay/%s/confirm' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret, p.pk),
{}
)
self.order.refresh_from_db()
p.refresh_from_db()
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert self.order.status == Order.STATUS_PAID
assert gc.value == Decimal('87.00')
def test_answer_download_token(self): def test_answer_download_token(self):
with scopes_disabled(): with scopes_disabled():
q = self.event.questions.create(question="Foo", type="F") q = self.event.questions.create(question="Foo", type="F")