mirror of
https://github.com/pretix/pretix.git
synced 2025-12-10 01:12:28 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d473f56c3a | ||
|
|
4138ab3d7d | ||
|
|
e18d1a451d | ||
|
|
a3048cd393 | ||
|
|
dd8fdc6c0a | ||
|
|
9099e4b709 | ||
|
|
52b176b9eb | ||
|
|
69fd70787c | ||
|
|
ff37aea9c8 | ||
|
|
85f73977bf | ||
|
|
2c04ed48c2 | ||
|
|
1228754280 | ||
|
|
a43ee054ad | ||
|
|
83bc714739 | ||
|
|
a08390c84a | ||
|
|
8b6eacecfe | ||
|
|
fb96787697 | ||
|
|
9cff77be62 | ||
|
|
0d1643da66 | ||
|
|
5e7027647a | ||
|
|
28f6f09e8f | ||
|
|
332af5d21b | ||
|
|
e187005130 | ||
|
|
0357386f7c | ||
|
|
47f8e5b8c6 | ||
|
|
e95c9d73a1 | ||
|
|
b7174070fe | ||
|
|
dd06a7b62c | ||
|
|
ff9d480b6e | ||
|
|
229ad9108b | ||
|
|
0e332d291a | ||
|
|
180904cdc2 | ||
|
|
0e83f7d807 | ||
|
|
5d7931fcaf | ||
|
|
2e906b0bf5 | ||
|
|
33ae6f12de | ||
|
|
f302c2e154 | ||
|
|
3ee2492382 |
258
doc/api/resources/carts.rst
Normal file
258
doc/api/resources/carts.rst
Normal 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.
|
||||
@@ -20,3 +20,4 @@ Resources and endpoints
|
||||
vouchers
|
||||
checkinlists
|
||||
waitinglist
|
||||
carts
|
||||
|
||||
@@ -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
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.16.0"
|
||||
__version__ = "1.17.0"
|
||||
|
||||
121
src/pretix/api/serializers/cart.py
Normal file
121
src/pretix/api/serializers/cart.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
46
src/pretix/api/views/cart.py
Normal file
46
src/pretix/api/views/cart.py
Normal 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()
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -225,6 +225,10 @@ DEFAULTS = {
|
||||
'default': None,
|
||||
'type': str
|
||||
},
|
||||
'mail_bcc': {
|
||||
'default': None,
|
||||
'type': str
|
||||
},
|
||||
'mail_from': {
|
||||
'default': settings.MAIL_FROM,
|
||||
'type': str
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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, '
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
21
src/pretix/plugins/manualpayment/__init__.py
Normal file
21
src/pretix/plugins/manualpayment/__init__.py
Normal 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'
|
||||
92
src/pretix/plugins/manualpayment/payment.py
Normal file
92
src/pretix/plugins/manualpayment/payment.py
Normal 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)
|
||||
10
src/pretix/plugins/manualpayment/signals.py
Normal file
10
src/pretix/plugins/manualpayment/signals.py
Normal 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
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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"> </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"> </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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ pep8-naming
|
||||
flake8
|
||||
codecov
|
||||
coverage
|
||||
pytest==3.0.*
|
||||
pytest==3.6.*
|
||||
pytest-django
|
||||
isort
|
||||
pytest-mock==1.4.*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
563
src/tests/api/test_cart.py
Normal 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.']
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user