Compare commits

...

38 Commits

Author SHA1 Message Date
Raphael Michel
d473f56c3a Bump version to 1.17.0 2018-07-08 16:26:39 +02:00
Raphael Michel
4138ab3d7d Merge pull request #960 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-07-08 16:07:15 +02:00
Raphael Michel
e18d1a451d Translated on translate.pretix.eu (Spanish)
Currently translated at 3.0% (76 of 2563 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2018-07-08 14:06:56 +00:00
Raphael Michel
a3048cd393 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2563 of 2563 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2018-07-08 14:03:51 +00:00
Raphael Michel
dd8fdc6c0a Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2563 of 2563 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2018-07-08 14:03:05 +00:00
Raphael Michel
9099e4b709 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2563 of 2563 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2018-07-08 14:01:16 +00:00
Raphael Michel
52b176b9eb Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-07-08 15:49:10 +02:00
Raphael Michel
69fd70787c Fix a missing request parameter for a permissions check 2018-07-08 15:48:48 +02:00
Raphael Michel
ff37aea9c8 Update from Weblate. (#949) 2018-07-08 15:48:36 +02:00
Dimas 3r1ck Rivas
85f73977bf Translated on translate.pretix.eu (Spanish)
Currently translated at 2.9% (76 of 2542 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2018-06-25 10:53:51 +00:00
Pernille Thorsen
2c04ed48c2 Translated on translate.pretix.eu (Danish)
Currently translated at 65.9% (1676 of 2542 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2018-06-25 10:53:51 +00:00
Pernille Thorsen
1228754280 Translated on translate.pretix.eu (Danish)
Currently translated at 65.8% (1674 of 2542 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2018-06-25 10:53:51 +00:00
Raphael Michel
a43ee054ad Fix logging of file upload questions 2018-06-25 12:53:45 +02:00
Raphael Michel
83bc714739 Widget: Hide "FREE" if there is only one priced item 2018-06-25 12:53:45 +02:00
Raphael Michel
a08390c84a Use device width for width calculation of widget 2018-06-25 12:53:45 +02:00
Raphael Michel
8b6eacecfe Add X-Robots-Tag to redirect responses 2018-06-25 12:53:45 +02:00
Raphael Michel
fb96787697 Fix #765 -- Include P3P header 2018-06-25 12:53:45 +02:00
Raphael Michel
9cff77be62 Add blacklist to git hook recommendatio 2018-06-24 16:14:58 +02:00
Raphael Michel
0d1643da66 Add manual payments 2018-06-24 16:14:29 +02:00
Raphael Michel
5e7027647a Add bcc option for event emails 2018-06-22 13:28:54 +02:00
Raphael Michel
28f6f09e8f Upgrade py.test version 2018-06-19 18:19:59 +02:00
Raphael Michel
332af5d21b Fix #815 -- Add configurable minimum/maximum amount for payment methods 2018-06-19 18:00:33 +02:00
Tobias Kunze
e187005130 Strip [] in mail subject prefix (#950) 2018-06-19 12:46:08 +02:00
Raphael Michel
0357386f7c Hide some links when printing 2018-06-15 17:48:30 +02:00
Raphael Michel
47f8e5b8c6 API: FIll meta info 2018-06-15 12:04:40 +02:00
Raphael Michel
e95c9d73a1 Badges: Sort by last name 2018-06-14 16:23:55 +02:00
Raphael Michel
b7174070fe Check-in list export: Excel dialect 2018-06-14 16:19:05 +02:00
Raphael Michel
dd06a7b62c Sync setup.py with requirements.txt 2018-06-13 11:09:18 +02:00
Raphael Michel
ff9d480b6e Orders API: Improve validation errors 2018-06-13 11:08:54 +02:00
Raphael Michel
229ad9108b Fix ticket exporter 2018-06-12 15:50:03 +02:00
Raphael Michel
0e332d291a Fix locale of download reminder email 2018-06-11 15:32:08 +02:00
Raphael Michel
180904cdc2 Fix KeyError 2018-06-11 14:29:29 +02:00
Raphael Michel
0e83f7d807 Add documentation on cart endpoints 2018-06-11 14:29:22 +02:00
Raphael Michel
5d7931fcaf API: CartPositions (#948) 2018-06-11 13:18:37 +02:00
Raphael Michel
2e906b0bf5 Always inlude mail addresses in check-in list CSV 2018-06-10 15:21:18 +02:00
Raphael Michel
33ae6f12de Fix links in item descriptions 2018-06-10 15:11:19 +02:00
Raphael Michel
f302c2e154 Fix log entries from deleted plugins 2018-06-10 14:50:08 +02:00
Raphael Michel
3ee2492382 Bump version to 1.17.0.dev0 2018-06-07 18:04:26 +02:00
64 changed files with 6270 additions and 3347 deletions

258
doc/api/resources/carts.rst Normal file
View File

@@ -0,0 +1,258 @@
.. _rest-carts:
Cart positions
==============
The API provides limited access to the cart position data model. This API currently only allows creating and deleting
cart positions to reserve quota.
Cart position resource
----------------------
The cart position resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the cart position
cart_id string Identifier of the cart this belongs to. Needs to end
in "@api" for API-created positions.
datetime datetime Time of creation
expires datetime The cart position will expire at this time and no longer block quota
item integer ID of the item
variation integer ID of the variation (or ``null``)
price money (string) Price of this position
attendee_name string Specified attendee name for this position (or ``null``)
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
===================================== ========================== =======================================================
.. versionchanged:: 1.17
This resource has been added.
Cart position endpoints
-----------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
Returns a list of API-created cart positions.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ 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
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_email": null,
"voucher": null,
"addon_to": null,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"answers": []
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
Returns information on one cart position, identified by its internal ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/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,
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_email": null,
"voucher": null,
"addon_to": null,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"answers": []
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the position to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested cart position does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
Creates a new cart position.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend.
There is a lot that it does not or can not do, and you will need to be careful using it.
It allows to bypass many of the restrictions imposed when creating a cart through the
regular shop.
Specifically, this endpoint currently
* does not validate if products are only to be sold in a specific time frame
* does not validate if the event's ticket sales are already over or haven't started
* does not support add-on products at the moment
* does not check or calculate prices but believes any prices you send
* does not support the redemption of vouchers
* does not prevent you from buying items that can only be bought with a voucher
* does not support file upload questions
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
* ``item``
* ``variation`` (optional)
* ``price``
* ``attendee_name`` (optional)
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional)
* ``answers``
* ``question``
* ``answer``
* ``options``
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_email": null,
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full cart position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event to create a position for
:param event: The ``slug`` field of the event to create a position for
:statuscode 201: no error
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
Deletes a cart position, identified by its internal ID.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/cartpositions/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/json
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the position to delete
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested cart position does not exist.

View File

@@ -20,3 +20,4 @@ Resources and endpoints
vouchers
checkinlists
waitinglist
carts

View File

@@ -490,6 +490,9 @@ Order endpoints
``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be
sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call
the ``mark_paid`` API method.
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas that become free by this operation will be credited to your order
creation.
* ``email``
* ``locale``
* ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing
@@ -580,11 +583,11 @@ Order endpoints
{
"positionid": 1,
"item": 1,
"variation": None,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_email": None,
"addon_to": None,
"attendee_email": null,
"addon_to": null,
"answers": [
{
"question": 1,
@@ -592,7 +595,7 @@ Order endpoints
"options": []
}
],
"subevent": None
"subevent": null
}
],
}

View File

@@ -122,13 +122,15 @@ for example, to check for any errors in any staged files when committing::
export GIT_WORK_TREE=../
export GIT_DIR=../.git
source ../env/bin/activate # Adjust to however you activate your virtual environment
for file in $(git diff --cached --name-only | grep -E '\.py$')
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
do
echo $file
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
done
This keeps you from accidentally creating commits violating the style guide.
Working with mails

View File

@@ -1 +1 @@
__version__ = "1.16.0"
__version__ = "1.17.0"

View File

@@ -0,0 +1,121 @@
from datetime import timedelta
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer,
)
from pretix.base.models import Quota
from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',)
def create(self, validated_data):
answers_data = validated_data.pop('answers')
if not validated_data.get('cart_id'):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
validated_data['cart_id'] = cid
if not validated_data.get('expires'):
validated_data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
with self.context['event'].lock():
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
if validated_data.get('variation')
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0:
raise ValidationError(
ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError(
ugettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
if not item.active:
raise ValidationError(
'The specified item is not active.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data):
if data.get('item'):
if data.get('item').has_variations:
if not data.get('variation'):
raise ValidationError('You should specify a variation for this item.')
else:
if data.get('variation').item != data.get('item'):
raise ValidationError(
'The specified variation does not belong to the specified item.'
)
elif data.get('variation'):
raise ValidationError(
'You cannot specify a variation for this item.'
)
return data

View File

@@ -3,6 +3,7 @@ from collections import Counter
from decimal import Decimal
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy
from django_countries.fields import Country
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
@@ -11,9 +12,9 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer, Quota,
Question, QuestionAnswer,
)
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import CartPosition, OrderFee
from pretix.base.pdf import get_variables
from pretix.base.signals import register_ticket_outputs
@@ -298,15 +299,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
if data.get('item'):
if data.get('item').has_variations:
if not data.get('variation'):
raise ValidationError('You should specify a variation for this item.')
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation').item != data.get('item'):
raise ValidationError(
'The specified variation does not belong to the specified item.'
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation'):
raise ValidationError(
'You cannot specify a variation for this item.'
{'variation': ['You cannot specify a variation for this item.']}
)
return data
@@ -340,11 +341,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
comment = serializers.CharField(required=False, allow_blank=True)
payment_provider = serializers.CharField(required=True)
payment_info = CompatibleJSONField(required=False)
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
class Meta:
model = Order
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
'invoice_address', 'positions', 'checkin_attention', 'payment_info')
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
def validate_payment_provider(self, pp):
if pp not in self.context['event'].get_payment_providers():
@@ -367,28 +369,42 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
'An order cannot be empty.'
)
errs = [{} for p in data]
if any([p.get('positionid') for p in data]):
if not all([p.get('positionid') for p in data]):
raise ValidationError(
'If you set position IDs manually, you need to do so for all positions.'
)
for i, p in enumerate(data):
if not p.get('positionid'):
errs[i]['positionid'] = [
'If you set position IDs manually, you need to do so for all positions.'
]
raise ValidationError(errs)
last_non_add_on = None
last_posid = 0
for p in data:
for i, p in enumerate(data):
if p['positionid'] != last_posid + 1:
raise ValidationError("Position IDs need to be consecutive.")
errs[i]['positionid'] = [
'Position IDs need to be consecutive.'
]
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
raise ValidationError("If you set addon_to, you need to make sure that the referenced "
"position ID exists and is transmitted directly before its add-ons.")
errs[i]['addon_to'] = [
"If you set addon_to, you need to make sure that the referenced "
"position ID exists and is transmitted directly before its add-ons."
]
if not p.get('addon_to'):
last_non_add_on = p['positionid']
last_posid = p['positionid']
elif any([p.get('addon_to') for p in data]):
raise ValidationError("If you set addon_to, you need to specify position IDs manually.")
errs = [
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data
]
if any(errs):
raise ValidationError(errs)
return data
def create(self, validated_data):
@@ -399,26 +415,58 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else:
ia = None
with self.context['event'].lock():
with self.context['event'].lock() as now_dt:
quotadiff = Counter()
for pos_data in positions_data:
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
if consume_carts:
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
for quota in quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.expires > now_dt:
quotadiff.subtract(quotas)
delete_cps.append(cp)
errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
if len(new_quotas) == 0:
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item'))
)]
else:
for quota in new_quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] -= 1
if quota_avail_cache[quota][1] < 0:
errs[i]['item'] = [
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name
)
]
quotadiff.update(new_quotas)
for quota, diff in quotadiff.items():
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise ValidationError(
'There is not enough quota available on quota "{}" to perform the operation.'.format(
quota.name
)
)
if any(errs):
raise ValidationError({'positions': errs})
order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p['subevent'] for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
order.meta_info = "{}"
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.payment_provider = 'free'
order.status = Order.STATUS_PAID
@@ -445,6 +493,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
options = answ_data.pop('options')
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
for cp in delete_cps:
cp.delete()
for fee_data in fees_data:
f = OrderFee(**fee_data)
f.order = order

View File

@@ -4,6 +4,8 @@ from django.apps import apps
from django.conf.urls import include, url
from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
)
@@ -28,6 +30,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)

View File

@@ -0,0 +1,46 @@
from django.db import transaction
from rest_framework import status, viewsets
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = CartPositionSerializer
queryset = CartPosition.objects.none()
filter_backends = (OrderingFilter,)
ordering = ('datetime',)
ordering_fields = ('datetime', 'cart_id')
lookup_field = 'id'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return CartPosition.objects.filter(
event=self.request.event,
cart_id__endswith="@api"
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def create(self, request, *args, **kwargs):
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()

View File

@@ -172,6 +172,12 @@ class SecurityMiddleware(MiddlewareMixin):
return resp
resp['X-XSS-Protection'] = '1'
# We just need to have a P3P, not matter whats in there
# https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
h = {
'default-src': ["{static}"],
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],

View File

@@ -66,10 +66,13 @@ class LogEntry(models.Model):
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
if self.content_type.model_class() is Event:
return ''
try:
if self.content_type.model_class() is Event:
return ''
co = self.content_object
co = self.content_object
except:
return ''
a_map = None
a_text = None

View File

@@ -7,6 +7,7 @@ import pytz
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.dispatch import receiver
from django.forms import Form
from django.http import HttpRequest
@@ -185,6 +186,28 @@ class BasePaymentProvider:
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
help_text=_('This payment will be available only if the order total is equal to or exceeds the given '
'value. The order total for this purpose may be computed without taking the fees imposed '
'by this payment method into account.'),
localize=True,
required=False,
decimal_places=places,
widget=DecimalTextInput(places=places)
)),
('_total_max',
forms.DecimalField(
label=_('Maximum order total'),
help_text=_('This payment will be available only if the order total is equal to or below the given '
'value. The order total for this purpose may be computed without taking the fees imposed '
'by this payment method into account.'),
localize=True,
required=False,
decimal_places=places,
widget=DecimalTextInput(places=places)
)),
('_fee_abs',
forms.DecimalField(
label=_('Additional fee'),
@@ -304,16 +327,36 @@ class BasePaymentProvider:
return True
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
"""
You can use this method to disable this payment provider for certain groups
of users, products or other criteria. If this method returns ``False``, the
user will not be able to select this payment method. This will only be called
during checkout, not on retrying.
The default implementation checks for the _availability_date setting to be either unset or in the future.
The default implementation checks for the _availability_date setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met.
:param total: The total value without the payment method fee, after taxes.
.. versionchanged:: 1.17.0
The ``total`` parameter has been added. For backwards compatibility, this method is called again
without this parameter if it raises a ``TypeError`` on first try.
"""
return self._is_still_available(cart_id=get_or_create_cart_id(request))
timing = self._is_still_available(cart_id=get_or_create_cart_id(request))
pricing = True
if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None:
raise ImproperlyConfigured('This payment provider does not support maximum or minimum amounts.')
if self.settings._total_max is not None:
pricing = pricing and total <= Decimal(self.settings._total_max)
if self.settings._total_min is not None:
pricing = pricing and total >= Decimal(self.settings._total_min)
return timing and pricing
def payment_form_render(self, request: HttpRequest) -> str:
"""
@@ -451,6 +494,12 @@ class BasePaymentProvider:
:param order: The order object
"""
if self.settings._total_max is not None and order.total > Decimal(self.settings._total_max):
return False
if self.settings._total_min is not None and order.total < Decimal(self.settings._total_min):
return False
return self._is_still_available(order=order)
def order_can_retry(self, order: Order) -> bool:
@@ -647,7 +696,7 @@ class FreeOrderProvider(BasePaymentProvider):
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal) -> bool:
from .services.cart import get_fees
total = get_cart_total(request)
@@ -684,7 +733,7 @@ class BoxOfficeProvider(BasePaymentProvider):
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:

View File

@@ -106,14 +106,19 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
'color': '#8E44B3'
}
bcc = []
if event:
htmlctx['event'] = event
htmlctx['color'] = event.settings.primary_color
if event.settings.mail_bcc:
bcc.append(event.settings.mail_bcc)
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = event.settings.contact_mail
prefix = event.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
subject = "[%s] %s" % (prefix, subject)
@@ -151,6 +156,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
send_task = mail_send_task.si(
to=[email],
bcc=bcc,
subject=subject,
body=body_plain,
html=body_html,

View File

@@ -646,24 +646,25 @@ def send_download_reminders(sender, **kwargs):
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
continue
o.download_reminder_sent = True
o.save()
email_template = e.settings.mail_text_download_reminder
email_context = {
'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
}),
}
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent'
)
except SendMailException:
logger.exception('Reminder email could not be sent')
with language(o.locale):
o.download_reminder_sent = True
o.save()
email_template = e.settings.mail_text_download_reminder
email_context = {
'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
}),
}
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent'
)
except SendMailException:
logger.exception('Reminder email could not be sent')
class OrderChangeManager:

View File

@@ -225,6 +225,10 @@ DEFAULTS = {
'default': None,
'type': str
},
'mail_bcc': {
'default': None,
'type': str
},
'mail_from': {
'default': settings.MAIL_FROM,
'type': str

View File

@@ -11,7 +11,9 @@ def redir_view(request):
url = signer.unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
return HttpResponseRedirect(url)
r = HttpResponseRedirect(url)
r['X-Robots-Tag'] = 'noindex'
return r
def safelink(url):

View File

@@ -663,6 +663,11 @@ class MailSettingsForm(SettingsForm):
label=_("Sender address"),
help_text=_("Sender address for outgoing emails")
)
mail_bcc = forms.EmailField(
label=_("Bcc address"),
help_text=_("All emails will be sent to this address as a Bcc copy"),
required=False
)
mail_text_signature = I18nFormField(
label=_("Signature"),

View File

@@ -11,6 +11,7 @@
{% bootstrap_field form.mail_prefix layout="control" %}
{% bootstrap_field form.mail_from layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail content" %}</legend>

View File

@@ -7,13 +7,13 @@
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
{% if 'can_change_organizer_settings' in request.orgapermset %}
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
class="btn btn-default">
class="btn btn-default hidden-print">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
{% endif %}
</h1>
<ul class="nav nav-pills">
<ul class="nav nav-pills hidden-print">
<li {% if "organizer" == url_name %}class="active"{% endif %}>
<a href="{% url "control:organizer" organizer=organizer.slug %}">
{% trans "Events" %}

View File

@@ -7,6 +7,7 @@ import pytz
import vat_moss.id
from django.conf import settings
from django.contrib import messages
from django.core.files import File
from django.core.urlresolvers import reverse
from django.db.models import Count
from django.http import FileResponse, Http404, HttpResponseNotAllowed
@@ -698,7 +699,12 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
self.invoice_form.save()
self.order.log_action('pretix.event.order.modified', {
'invoice_data': self.invoice_form.cleaned_data,
'data': [f.cleaned_data for f in self.forms]
'data': [{
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
} for f in self.forms]
}, user=request.user)
if self.invoice_form.has_changed():
success_message = ('The invoice address has been updated. If you want to generate a new invoice, '

View File

@@ -70,7 +70,7 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
else:
prod = '%s' % str(v.item)
elif v.quota:
prod = _('Any product in quota "{quota}"').format(uota=str(v.quota.name))
prod = _('Any product in quota "{quota}"').format(quota=str(v.quota.name))
row = [
v.code,
v.valid_until.isoformat() if v.valid_until else "",

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-04-24 14:22+0000\n"
"Last-Translator: Pernille Thorsen <perth@aarhus.dk>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-06-05 13:05+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-06-05 13:04+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-05-31 02:00+0000\n"
"Last-Translator: Lorenzo Peña <lorinkoz@nauta.cu>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: French\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-05-18 09:31+0000\n"
"Last-Translator: Eric Daras <eric@daras.family>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-06-05 13:42+0000\n"
"Last-Translator: Maarten van den Berg <maartenberg1@gmail.com>\n"
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix-js/"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-06 14:14+0000\n"
"POT-Creation-Date: 2018-07-08 13:49+0000\n"
"PO-Revision-Date: 2018-06-04 19:48+0000\n"
"Last-Translator: wallber azevedo pinheiro <wallpinheiro@gmail.com>\n"
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"

View File

@@ -83,6 +83,14 @@ class BadgeExporter(BaseExporter):
label=_('Include pending orders'),
required=False
)),
('order_by',
forms.ChoiceField(
label=_('Sort by'),
choices=(
('name', _('Attendee name')),
('last_name', _('Last part of attendee name')),
)
)),
]
)
return d
@@ -99,5 +107,11 @@ class BadgeExporter(BaseExporter):
else:
qs = qs.filter(order__status__in=[Order.STATUS_PAID])
if form_data.get('order_by') == 'name':
qs = qs.order_by('attendee_name', 'order__code')
elif form_data.get('order_by') == 'last_name':
qs = qs.order_by('order__code')
qs = sorted(qs, key=lambda op: op.attendee_name.split()[-1] if op.attendee_name else '')
outbuffer = render_pdf(self.event, qs)
return 'badges.pdf', 'application/pdf', outbuffer.read()

View File

@@ -17,7 +17,7 @@ def register_payment_provider(sender, **kwargs):
@receiver(nav_event, dispatch_uid="payment_banktransfer_nav")
def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'):
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
return []
return [
{

View File

@@ -244,9 +244,25 @@ class CSVCheckinList(BaseCheckinList):
identifier = 'checkinlistcsv'
verbose_name = ugettext_lazy('Check-in list (CSV)')
@property
def export_form_fields(self):
d = super().export_form_fields
d['dialect'] = forms.ChoiceField(
label=_('CSV dialect'),
choices=(
('default', 'Default'),
('excel', 'Excel')
)
)
return d
def render(self, form_data: dict):
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
if form_data.get('dialect', '-') in csv.list_dialects():
writer = csv.writer(output, dialect=form_data.get('dialect'))
else:
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
cl = self.event.checkin_lists.get(pk=form_data['list'])
questions = list(Question.objects.filter(event=self.event, id__in=form_data['questions']))
@@ -288,8 +304,7 @@ class CSVCheckinList(BaseCheckinList):
if form_data['secrets']:
headers.append(_('Secret'))
if self.event.settings.attendee_emails_asked:
headers.append(_('E-mail'))
headers.append(_('E-mail'))
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
@@ -319,8 +334,7 @@ class CSVCheckinList(BaseCheckinList):
row.append(_('Yes') if op.order.status == Order.STATUS_PAID else _('No'))
if form_data['secrets']:
row.append(op.secret)
if self.event.settings.attendee_emails_asked:
row.append(op.attendee_email or (op.addon_to.attendee_email if op.addon_to else ''))
row.append(op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '') or op.order.email or '')
if self.event.has_subevents:
row.append(str(op.subevent))
acache = {}

View File

@@ -0,0 +1,21 @@
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
from pretix import __version__ as version
class ManualPaymentApp(AppConfig):
name = 'pretix.plugins.manualpayment'
verbose_name = _("Manual payment")
class PretixPluginMeta:
name = _("Manual payment")
author = _("the pretix team")
version = version
description = _("This plugin adds a customizable payment method for manual processing.")
def ready(self):
from . import signals # NOQA
default_app_config = 'pretix.plugins.manualpayment.ManualPaymentApp'

View File

@@ -0,0 +1,92 @@
from collections import OrderedDict
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.forms import PlaceholderValidator
from pretix.base.payment import BasePaymentProvider
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString))
@property
def settings_form_fields(self):
d = OrderedDict(
[
('public_name', I18nFormField(
label=_('Payment method name'),
widget=I18nTextInput,
)),
('checkout_description', I18nFormField(
label=_('Payment process description during checkout'),
help_text=_('This text will be shown during checkout when the user selects this payment method. '
'It should give a short explanation on this payment method.'),
widget=I18nTextarea,
)),
('email_instructions', I18nFormField(
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
('pending_description', I18nFormField(
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
] + list(super().settings_form_fields.items())
)
d.move_to_end('_enabled', last=False)
return d
def payment_form_render(self, request) -> str:
return rich_text(
str(self.settings.get('checkout_description', as_type=LazyI18nString))
)
def checkout_prepare(self, request, total):
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request):
return self.payment_form_render(request)
def format_map(self, order):
return {
'order': order.code,
'total': order.total,
'currency': self.event.currency,
'total_with_currency': money_filter(order.total, self.event.currency)
}
def order_pending_mail_render(self, order) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
return msg
def order_pending_render(self, request, order) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
)
def order_control_render(self, request, order) -> str:
template = get_template('pretixplugins/manualpayment/control.html')
ctx = {'request': request, 'event': self.event,
'order': order}
return template.render(ctx)

View File

@@ -0,0 +1,10 @@
from django.dispatch import receiver
from pretix.base.signals import register_payment_providers
from .payment import ManualPayment
@receiver(register_payment_providers, dispatch_uid="payment_manual")
def register_payment_provider(sender, **kwargs):
return ManualPayment

View File

@@ -0,0 +1,11 @@
{% load i18n %}
{% if order.status == "p" %}
<p>{% blocktrans trimmed %}
This order has been paid manually.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
This order has been planned to be paid manually, but is not marked as paid.
{% endblocktrans %}</p>
{% endif %}

View File

@@ -1,8 +1,11 @@
from io import BytesIO
from django.core.files.base import ContentFile
from django.utils.translation import ugettext as _
from PyPDF2.merger import PdfFileMerger
from pretix.base.exporter import BaseExporter
from pretix.base.i18n import language
from pretix.base.models import Order, OrderPosition
from .ticketoutput import PdfTicketOutput
@@ -14,19 +17,30 @@ class AllTicketsPDF(BaseExporter):
identifier = "pdfoutput_all_tickets"
def render(self, form_data):
merger = PdfFileMerger()
o = PdfTicketOutput(self.event)
qs = OrderPosition.objects.filter(order__event=self.event, order__status=Order.STATUS_PAID).select_related(
'order', 'item', 'variation'
)
buffer = BytesIO()
p = o._create_canvas(buffer)
for op in qs:
if op.addon_to_id and not self.event.settings.ticket_download_addons:
continue
if not op.item.admission and not self.event.settings.ticket_download_nonadm:
continue
o._draw_page(p, op, op.order)
p.save()
outbuffer = o._render_with_background(buffer)
with language(op.order.locale):
buffer = BytesIO()
p = o._create_canvas(buffer)
layout = o.layout_map.get(op.item_id, o.default_layout)
o._draw_page(layout, p, op, op.order)
p.save()
outbuffer = o._render_with_background(layout, buffer)
merger.append(ContentFile(outbuffer.read()))
outbuffer = BytesIO()
merger.write(outbuffer)
merger.close()
outbuffer.seek(0)
return '{}_tickets.pdf'.format(self.event.slug), 'application/pdf', outbuffer.read()

View File

@@ -437,7 +437,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def provider_forms(self):
providers = []
for provider in self.request.event.get_payment_providers().values():
if not provider.is_enabled or not provider.is_allowed(self.request):
if not provider.is_enabled or not self._is_allowed(provider, self.request):
continue
fee = provider.calculate_fee(self._total_order_value)
providers.append({
@@ -480,6 +480,12 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def payment_provider(self):
return self.request.event.get_payment_providers().get(self.cart_session['payment'])
def _is_allowed(self, prov, request):
try:
return prov.is_allowed(request, total=self._total_order_value)
except TypeError:
return prov.is_allowed(request)
def is_completed(self, request, warn=False):
self.request = request
if 'payment' not in self.cart_session or not self.payment_provider:
@@ -488,7 +494,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
return False
if not self.payment_provider.payment_is_valid_session(request) or \
not self.payment_provider.is_enabled or \
not self.payment_provider.is_allowed(request):
not self._is_allowed(self.payment_provider, request):
if warn:
messages.error(request, _('The payment information you entered was incomplete.'))
return False
@@ -499,7 +505,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
for p in self.request.event.get_payment_providers().values():
if p.is_implicit:
if p.is_allowed(request):
if self._is_allowed(p, request):
self.cart_session['payment'] = p.identifier
return False

View File

@@ -3,6 +3,7 @@ import os
from decimal import Decimal
from django.contrib import messages
from django.core.files import File
from django.db import transaction
from django.db.models import Sum
from django.http import FileResponse, Http404, JsonResponse
@@ -450,7 +451,12 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
self.invoice_form.save()
self.order.log_action('pretix.event.order.modified', {
'invoice_data': self.invoice_form.cleaned_data,
'data': [f.cleaned_data for f in self.forms]
'data': [{
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
} for f in self.forms]
})
if self.invoice_form.has_changed():
success_message = ('Your invoice address has been updated. Please contact us if you need us '

View File

@@ -242,6 +242,7 @@ INSTALLED_APPS = [
'pretix.plugins.checkinlists',
'pretix.plugins.pretixdroid',
'pretix.plugins.badges',
'pretix.plugins.manualpayment',
'django_markup',
'django_otp',
'django_otp.plugins.otp_totp',

View File

@@ -5,7 +5,10 @@ $(function () {
var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]';
$("details summary, details summary a").click(function (e) {
$("details summary, details summary a[data-toggle=variations]").click(function (e) {
if (this.tagName !== "A" && e.target.tagName === "A") {
return true;
}
var $details = $(this).closest("details");
var isOpen = $details.prop("open");
var $detailsNotSummary = $details.children(':not(summary)');

View File

@@ -255,8 +255,9 @@ Vue.component('variation', {
+ '<div class="pretix-widget-item-price-col">'
+ '<pricebox :price="variation.price" :free_price="item.free_price" :original_price="item.original_price"'
+ ' :field_name="\'price_\' + item.id + \'_\' + variation.id">'
+ ' :field_name="\'price_\' + item.id + \'_\' + variation.id" v-if="$root.showPrices">'
+ '</pricebox>'
+ '<span v-if="!$root.showPrices">&nbsp;</span>'
+ '</div>'
+ '<div class="pretix-widget-item-availability-col">'
+ '<availbox :item="item" :variation="variation"></availbox>'
@@ -299,10 +300,11 @@ Vue.component('item', {
+ '</div>'
+ '<div class="pretix-widget-item-price-col">'
+ '<pricebox :price="item.price" :free_price="item.free_price" v-if="!item.has_variations"'
+ '<pricebox :price="item.price" :free_price="item.free_price" v-if="!item.has_variations && $root.showPrices"'
+ ' :field_name="\'price_\' + item.id" :original_price="item.original_price">'
+ '</pricebox>'
+ '<div class="pretix-widget-pricebox" v-if="item.has_variations">{{ pricerange }}</div>'
+ '<div class="pretix-widget-pricebox" v-if="item.has_variations && $root.showPrices">{{ pricerange }}</div>'
+ '<span v-if="!$root.showPrices">&nbsp;</span>'
+ '</div>'
+ '<div class="pretix-widget-item-availability-col">'
+ '<a v-if="show_toggle" href="#" @click.prevent="expand">'+ strings.variations + '</a>'
@@ -648,7 +650,24 @@ var shared_root_computed = {
return form_target;
},
useIframe: function () {
return window.innerWidth >= 800 && (this.skip_ssl || site_is_secure());
return Math.min(screen.width, window.innerWidth) >= 800 && (this.skip_ssl || site_is_secure());
},
showPrices: function () {
var has_priced = false;
var cnt_items = 0;
for (var i = 0; i < this.categories.length; i++) {
for (var j = 0; j < this.categories[i].items.length; j++) {
var item = this.categories[i].items[j];
if (item.has_variations) {
cnt_items += item.variations.length;
has_priced = true;
} else {
cnt_items++;
has_priced = has_priced || item.price.gross != "0.00";
}
}
}
return has_priced || cnt_items > 1;
}
};

View File

@@ -7,7 +7,7 @@ pep8-naming
flake8
codecov
coverage
pytest==3.0.*
pytest==3.6.*
pytest-django
isort
pytest-mock==1.4.*

View File

@@ -48,3 +48,4 @@ django-countries
pyuca # for better sorting of country names in django-countries
defusedcsv>=1.0.1
vat_moss==0.11.0
idna==2.6 # required by current requests

View File

@@ -112,6 +112,7 @@ setup(
'vat_moss==0.11.0',
'django-hijack==2.1.*',
'django-oauth-toolkit==1.1.*',
'idna==2.6', # required by current requests
],
extras_require={
'dev': [
@@ -123,7 +124,7 @@ setup(
'pep8-naming',
'coveralls',
'coverage',
'pytest==2.9.*',
'pytest==3.6.*',
'pytest-django',
'isort',
'pytest-mock',

563
src/tests/api/test_cart.py Normal file
View File

@@ -0,0 +1,563 @@
import copy
import datetime
from decimal import Decimal
from unittest import mock
import pytest
from django.utils.timezone import now
from pytz import UTC
from pretix.base.models import Question
from pretix.base.models.orders import CartPosition
@pytest.fixture
def item(event):
return event.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def item2(event2):
return event2.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def taxrule(event):
return event.tax_rules.create(rate=Decimal('19.00'))
@pytest.fixture
def question(event, item):
q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC")
q.items.add(item)
q.options.create(answer="XL", identifier="LVETRWVU")
return q
@pytest.fixture
def question2(event2, item2):
q = event2.questions.create(question="T-Shirt size", type="S", identifier="ABC")
q.items.add(item2)
return q
@pytest.fixture
def quota(event, item):
q = event.quotas.create(name="Budget Quota", size=200)
q.items.add(item)
return q
TEST_CARTPOSITION_RES = {
'id': 1,
'cart_id': 'aaa@api',
'item': 1,
'variation': None,
'price': '23.00',
'attendee_name': None,
'attendee_email': None,
'voucher': None,
'addon_to': None,
'subevent': None,
'datetime': '2018-06-11T10:00:00Z',
'expires': '2018-06-11T10:00:00Z',
'includes_tax': True,
'answers': []
}
@pytest.mark.django_db
def test_cp_list(token_client, organizer, event, item, taxrule, question):
testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
cr = CartPosition.objects.create(
event=event, cart_id="aaa", item=item,
price=23,
datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0),
expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0)
)
res = dict(TEST_CARTPOSITION_RES)
res["id"] = cr.pk
res["item"] = item.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [] == resp.data['results']
@pytest.mark.django_db
def test_cp_list_api(token_client, organizer, event, item, taxrule, question):
testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
cr = CartPosition.objects.create(
event=event, cart_id="aaa@api", item=item,
price=23,
datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0),
expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0)
)
res = dict(TEST_CARTPOSITION_RES)
res["id"] = cr.pk
res["item"] = item.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_cp_detail(token_client, organizer, event, item, taxrule, question):
testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
cr = CartPosition.objects.create(
event=event, cart_id="aaa@api", item=item,
price=23,
datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0),
expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0)
)
res = dict(TEST_CARTPOSITION_RES)
res["id"] = cr.pk
res["item"] = item.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/cartpositions/{}/'.format(organizer.slug, event.slug,
cr.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_cp_delete(token_client, organizer, event, item, taxrule, question):
testtime = datetime.datetime(2018, 6, 11, 10, 0, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
cr = CartPosition.objects.create(
event=event, cart_id="aaa@api", item=item,
price=23,
datetime=datetime.datetime(2018, 6, 11, 10, 0, 0, 0),
expires=datetime.datetime(2018, 6, 11, 10, 0, 0, 0)
)
res = dict(TEST_CARTPOSITION_RES)
res["id"] = cr.pk
res["item"] = item.pk
resp = token_client.delete('/api/v1/organizers/{}/events/{}/cartpositions/{}/'.format(organizer.slug, event.slug,
cr.pk))
assert resp.status_code == 204
CARTPOS_CREATE_PAYLOAD = {
'cart_id': 'aaa@api',
'item': 1,
'variation': None,
'price': '23.00',
'attendee_name': None,
'attendee_email': None,
'addon_to': None,
'subevent': None,
'expires': '2018-06-11T10:00:00Z',
'includes_tax': True,
'answers': []
}
@pytest.mark.django_db
def test_cartpos_create(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
cp = CartPosition.objects.get(pk=resp.data['id'])
assert cp.price == Decimal('23.00')
assert cp.item == item
@pytest.mark.django_db
def test_cartpos_cart_id_noapi(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['cart_id'] = 'aaa'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
@pytest.mark.django_db
def test_cartpos_cart_id_optional(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
del res['cart_id']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
cp = CartPosition.objects.get(pk=resp.data['id'])
assert cp.price == Decimal('23.00')
assert cp.item == item
assert len(cp.cart_id) > 48
@pytest.mark.django_db
def test_cartpos_create_subevent_validation(token_client, organizer, event, item, subevent, subevent2, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'subevent': ['You need to set a subevent.']}
res['subevent'] = subevent2.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'subevent': ['The specified subevent does not belong to this event.']}
@pytest.mark.django_db
def test_cartpos_create_item_validation(token_client, organizer, event, item, item2, quota, question):
item.active = False
item.save()
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'item': ['The specified item is not active.']}
item.active = True
item.save()
res['item'] = item2.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'item': ['The specified item does not belong to this event.']}
var2 = item2.variations.create(value="A")
res['item'] = item.pk
res['variation'] = var2.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'non_field_errors': ['You cannot specify a variation for this item.']}
var1 = item.variations.create(value="A")
res['item'] = item.pk
res['variation'] = var1.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The product "Budget Ticket" is not assigned to a quota.']
quota.variations.add(var1)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
res['variation'] = var2.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'non_field_errors': ['The specified variation does not belong to the specified item.']}
res['variation'] = None
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'non_field_errors': ['You should specify a variation for this item.']}
@pytest.mark.django_db
def test_cartpos_expires_optional(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
del res['expires']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
cp = CartPosition.objects.get(pk=resp.data['id'])
assert cp.price == Decimal('23.00')
assert cp.item == item
assert cp.expires - now() > datetime.timedelta(minutes=25)
assert cp.expires - now() < datetime.timedelta(minutes=35)
@pytest.mark.django_db
def test_cartpos_create_answer_validation(token_client, organizer, event, item, quota, question, question2):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['answers'] = [{
"question": 1,
"answer": "S",
"options": []
}]
res['item'] = item.pk
res['answers'][0]['question'] = question2.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'question': ['The specified question does not belong to this event.']}]}
res['answers'][0]['question'] = question.pk
res['answers'][0]['options'] = [question.options.first().pk]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'answers': [{'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]}
question.type = Question.TYPE_CHOICE
question.save()
res['answers'][0]['options'] = []
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}
question.options.create(answer="L")
res['answers'][0]['options'] = [
question.options.first().pk,
question.options.last().pk,
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}
question.type = Question.TYPE_FILE
question.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]}
question.type = Question.TYPE_CHOICE_MULTIPLE
question.save()
res['answers'][0]['options'] = [
question.options.first().pk,
question.options.last().pk,
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.question == question
assert answ.answer == "XL, L"
question.type = Question.TYPE_NUMBER
question.save()
res['answers'][0]['options'] = []
res['answers'][0]['answer'] = '3.45'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "3.45"
question.type = Question.TYPE_NUMBER
question.save()
res['answers'][0]['options'] = []
res['answers'][0]['answer'] = 'foo'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': ['A valid number is required.']}]}
question.type = Question.TYPE_BOOLEAN
question.save()
res['answers'][0]['options'] = []
res['answers'][0]['answer'] = 'True'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "True"
question.type = Question.TYPE_BOOLEAN
question.save()
res['answers'][0]['answer'] = '0'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "False"
question.type = Question.TYPE_BOOLEAN
question.save()
res['answers'][0]['answer'] = 'bla'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]}
question.type = Question.TYPE_DATE
question.save()
res['answers'][0]['answer'] = '2018-05-14'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "2018-05-14"
question.type = Question.TYPE_DATE
question.save()
res['answers'][0]['answer'] = 'bla'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'answers': [{'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]].']}]}
question.type = Question.TYPE_DATETIME
question.save()
res['answers'][0]['answer'] = '2018-05-14T13:00:00Z'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "2018-05-14 13:00:00+00:00"
question.type = Question.TYPE_DATETIME
question.save()
res['answers'][0]['answer'] = 'bla'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [{'non_field_errors': [
'Datetime has wrong format. Use one of these formats instead: '
'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].']}]}
question.type = Question.TYPE_TIME
question.save()
res['answers'][0]['answer'] = '13:00:00'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
pos = CartPosition.objects.get(pk=resp.data['id'])
answ = pos.answers.first()
assert answ.answer == "13:00:00"
question.type = Question.TYPE_TIME
question.save()
res['answers'][0]['answer'] = 'bla'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'answers': [
{'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]}
@pytest.mark.django_db
def test_cartpos_create_quota_validation(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
quota.size = 0
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.']

View File

@@ -11,7 +11,7 @@ from django_countries.fields import Country
from pytz import UTC
from pretix.base.models import InvoiceAddress, Order, OrderPosition, Question
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import CartPosition, OrderFee
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice,
)
@@ -189,7 +189,8 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi
assert [] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format(
organizer.slug, event.slug, (order.last_modified - datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
organizer.slug, event.slug,
(order.last_modified - datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format(
@@ -197,7 +198,8 @@ def test_order_list(token_client, organizer, event, order, item, taxrule, questi
))
assert [res] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?modified_since={}'.format(
organizer.slug, event.slug, (order.last_modified + datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
organizer.slug, event.slug,
(order.last_modified + datetime.timedelta(hours=1)).isoformat().replace('+00:00', 'Z')
))
assert [] == resp.data['results']
@@ -1141,6 +1143,7 @@ def test_order_create_item_validation(token_client, organizer, event, item, item
assert resp.data == {'positions': [{'item': ['The specified item does not belong to this event.']}]}
var2 = item2.variations.create(value="A")
quota.variations.add(var2)
res['positions'][0]['item'] = item.pk
res['positions'][0]['variation'] = var2.pk
@@ -1150,7 +1153,7 @@ def test_order_create_item_validation(token_client, organizer, event, item, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'non_field_errors': ['You cannot specify a variation for this item.']}]}
assert resp.data == {'positions': [{'variation': ['You cannot specify a variation for this item.']}]}
var1 = item.variations.create(value="A")
res['positions'][0]['item'] = item.pk
@@ -1160,6 +1163,15 @@ def test_order_create_item_validation(token_client, organizer, event, item, item
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'item': ['The product "Budget Ticket" is not assigned to a quota.']}]}
quota.variations.add(var1)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
res['positions'][0]['variation'] = var2.pk
@@ -1169,7 +1181,8 @@ def test_order_create_item_validation(token_client, organizer, event, item, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'non_field_errors': ['The specified variation does not belong to the specified item.']}]}
assert resp.data == {
'positions': [{'variation': ['The specified variation does not belong to the specified item.']}]}
res['positions'][0]['variation'] = None
resp = token_client.post(
@@ -1178,7 +1191,7 @@ def test_order_create_item_validation(token_client, organizer, event, item, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'non_field_errors': ['You should specify a variation for this item.']}]}
assert resp.data == {'positions': [{'variation': ['You should specify a variation for this item.']}]}
@pytest.mark.django_db
@@ -1253,9 +1266,18 @@ def test_order_create_positionid_validation(token_client, organizer, event, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': ['If you set addon_to, you need to make sure that the '
'referenced position ID exists and is transmitted directly '
'before its add-ons.']}
assert resp.data == {
'positions': [
{},
{
'addon_to': [
'If you set addon_to, you need to make sure that the '
'referenced position ID exists and is transmitted directly '
'before its add-ons.'
]
}
]
}
res['positions'] = [
{
@@ -1285,7 +1307,10 @@ def test_order_create_positionid_validation(token_client, organizer, event, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': ['If you set addon_to, you need to specify position IDs manually.']}
assert resp.data == {'positions': [
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]},
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
]}
res['positions'] = [
{
@@ -1314,7 +1339,14 @@ def test_order_create_positionid_validation(token_client, organizer, event, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': ['If you set position IDs manually, you need to do so for all positions.']}
assert resp.data == {
'positions': [
{},
{
'positionid': ['If you set position IDs manually, you need to do so for all positions.']
}
]
}
res['positions'] = [
{
@@ -1344,7 +1376,14 @@ def test_order_create_positionid_validation(token_client, organizer, event, item
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': ['Position IDs need to be consecutive.']}
assert resp.data == {
'positions': [
{},
{
'positionid': ['Position IDs need to be consecutive.']
}
]
}
@pytest.mark.django_db
@@ -1358,7 +1397,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'question': ['The specified question does not belong to this event.']}]}]}
assert resp.data == {
'positions': [{'answers': [{'question': ['The specified question does not belong to this event.']}]}]}
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['answers'][0]['options'] = [question.options.first().pk]
@@ -1368,7 +1408,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]}]}
assert resp.data == {'positions': [{'answers': [
{'non_field_errors': ['You should not specify options if the question is not of a choice type.']}]}]}
question.type = Question.TYPE_CHOICE
question.save()
@@ -1379,7 +1420,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]}
assert resp.data == {'positions': [
{'answers': [{'non_field_errors': ['You need to specify options if the question is of a choice type.']}]}]}
question.options.create(answer="L")
res['positions'][0]['answers'][0]['options'] = [
@@ -1392,7 +1434,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]}
assert resp.data == {
'positions': [{'answers': [{'non_field_errors': ['You can specify at most one option for this question.']}]}]}
question.type = Question.TYPE_FILE
question.save()
@@ -1402,7 +1445,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]}]}
assert resp.data == {
'positions': [{'answers': [{'non_field_errors': ['File uploads are currently not supported via the API.']}]}]}
question.type = Question.TYPE_CHOICE_MULTIPLE
question.save()
@@ -1487,7 +1531,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]}]}
assert resp.data == {
'positions': [{'answers': [{'non_field_errors': ['Please specify "true" or "false" for boolean questions.']}]}]}
question.type = Question.TYPE_DATE
question.save()
@@ -1512,7 +1557,8 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]].']}]}]}
assert resp.data == {'positions': [{'answers': [
{'non_field_errors': ['Date has wrong format. Use one of these formats instead: YYYY[-MM[-DD]].']}]}]}
question.type = Question.TYPE_DATETIME
question.save()
@@ -1564,27 +1610,13 @@ def test_order_create_answer_validation(token_client, organizer, event, item, qu
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {'positions': [{'answers': [{'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]}]}
assert resp.data == {'positions': [{'answers': [
{'non_field_errors': ['Time has wrong format. Use one of these formats instead: hh:mm[:ss[.uuuuuu]].']}]}]}
@pytest.mark.django_db
def test_order_create_quota_validation(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
quota.size = 0
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.']
quota.size = 1
quota.save()
res['positions'] = [
{
"positionid": 1,
@@ -1609,13 +1641,72 @@ def test_order_create_quota_validation(token_client, organizer, event, item, quo
"subevent": None
}
]
quota.size = 0
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.']
assert resp.data == {
'positions': [
{'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']},
{'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']},
]
}
quota.size = 1
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{},
{'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']},
]
}
@pytest.mark.django_db
def test_order_create_quota_consume_cart(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
cr = CartPosition.objects.create(
event=event, cart_id="uxLJBUMEcnxOLI2EuxLYN1hWJq9GKu4yWL9FEgs2m7M0vdFi@api", item=item,
price=23,
expires=now() + datetime.timedelta(hours=3)
)
quota.size = 1
quota.save()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'item': ['There is not enough quota available on quota "Budget Quota" to perform the operation.']},
]
}
res['consume_carts'] = [cr.cart_id]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
assert not CartPosition.objects.filter(pk=cr.pk).exists()
@pytest.mark.django_db

View File

@@ -688,6 +688,49 @@ class CheckoutTestCase(TestCase):
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_payment_max_value(self):
self.event.settings.set('payment_stripe__enabled', True)
self.event.settings.set('payment_banktransfer__total_max', Decimal('42.00'))
self.event.settings.set('payment_banktransfer__enabled', True)
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]')), 2)
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]')), 1)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer'
}, follow=True)
self.assertEqual(response.status_code, 200)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select(".alert-danger")
def test_payment_min_value(self):
self.event.settings.set('payment_stripe__enabled', True)
self.event.settings.set('payment_banktransfer__total_min', Decimal('42.00'))
self.event.settings.set('payment_banktransfer__enabled', True)
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]')), 1)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer'
}, follow=True)
self.assertEqual(response.status_code, 200)
doc = BeautifulSoup(response.rendered_content, "lxml")
assert doc.select(".alert-danger")
def test_premature_confirm(self):
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),