mirror of
https://github.com/pretix/pretix.git
synced 2026-06-20 02:46:14 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d169958687 | |||
| 29a36057ed | |||
| 5eeecf9214 | |||
| 5992abcb7d | |||
| 0db7ec3169 | |||
| 8046bf98b7 | |||
| 9ed39ab0fa | |||
| 7e79fc8b5e | |||
| 9da68645da | |||
| f7a4b66da1 | |||
| c9212a483b | |||
| cc4e946d95 | |||
| 9d1cfd1eb6 | |||
| 38969747f4 | |||
| 6e7af4c64b | |||
| fb45f9f08c | |||
| 6848ce24eb | |||
| dac4fd8d3c | |||
| 6905d3e801 | |||
| 909b16be64 | |||
| a18162cc47 | |||
| 6f0fc9ed49 | |||
| 2409c513d6 | |||
| 0a95f90012 | |||
| edbd24e942 | |||
| 3940af868b | |||
| 8b4197d868 | |||
| 632e441c24 | |||
| c73ede81ae | |||
| c4b7aeaaa2 | |||
| b5bd98336a | |||
| 5af52f6087 | |||
| c5e4d06921 | |||
| 917cc00091 | |||
| 63cb88bfb8 | |||
| ac1fe15b6c | |||
| ddaa0570bc | |||
| 07352743f2 | |||
| f99ef5fff2 | |||
| 9d686072e2 | |||
| 4e44a2809b | |||
| 370e4eafc2 | |||
| b7ec372ebc | |||
| 60cdfe4029 | |||
| 74e14285ee | |||
| 8f56ab54a4 | |||
| 4ac58654a0 | |||
| 167eb06aeb | |||
| 9a0cc7e8c1 | |||
| d4ff1808d5 | |||
| 0ff22786cb | |||
| abfb53872c | |||
| 67f60a9e09 | |||
| 1d04d40507 | |||
| 14fdd7cfca | |||
| 402ed61756 | |||
| 66c75cbb1b | |||
| c32791c7dd | |||
| d6846d8415 | |||
| b1c8efa33f | |||
| f14d031de4 | |||
| 25c86db6f5 | |||
| 7205d0689e | |||
| cde46012cb | |||
| e4a0122938 | |||
| 77c08cb710 | |||
| af49a02047 |
@@ -1 +0,0 @@
|
||||
-r doc/requirements.txt
|
||||
@@ -0,0 +1,15 @@
|
||||
version: 2
|
||||
sphinx:
|
||||
configuration: doc/conf.py
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.8"
|
||||
nodejs: "16"
|
||||
apt_packages:
|
||||
- gettext
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: ./src/
|
||||
- requirements: doc/requirements.rtd.txt
|
||||
@@ -17,8 +17,8 @@ The cart position resource contains the following public fields:
|
||||
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.
|
||||
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
|
||||
@@ -29,14 +29,15 @@ attendee_name_parts object of strings Composition of
|
||||
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``).
|
||||
is_bundled boolean If ``addon_to`` is set, this shows whether this is a bundled product or an addon product
|
||||
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
|
||||
seat objects The assigned seat. Can be ``null``.
|
||||
seat objects The assigned seat (or ``null``)
|
||||
├ id integer Internal ID of the seat instance
|
||||
├ name string Human-readable seat name
|
||||
└ seat_guid string Identifier of the seat within the seating plan
|
||||
@@ -46,6 +47,10 @@ seat objects The assigned se
|
||||
|
||||
This ``seat`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 4.14
|
||||
|
||||
This ``is_bundled`` attribute has been added and the cart creation endpoints have been updated.
|
||||
|
||||
|
||||
Cart position endpoints
|
||||
-----------------------
|
||||
@@ -87,6 +92,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"is_bundled": false,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
@@ -133,6 +139,7 @@ Cart position endpoints
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
"is_bundled": false,
|
||||
"subevent": null,
|
||||
"datetime": "2018-06-11T10:00:00Z",
|
||||
"expires": "2018-06-11T10:00:00Z",
|
||||
@@ -168,7 +175,7 @@ Cart position endpoints
|
||||
|
||||
* 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 validate constraints on add-on products at the moment
|
||||
|
||||
* does not check or calculate prices but believes any prices you send
|
||||
|
||||
@@ -176,6 +183,8 @@ Cart position endpoints
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
Note that more validation might be added in the future, so please do not rely on missing validation.
|
||||
|
||||
You can supply the following fields of the resource:
|
||||
|
||||
* ``cart_id`` (optional, needs to end in ``@api``)
|
||||
@@ -190,6 +199,8 @@ Cart position endpoints
|
||||
* ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
|
||||
* ``sales_channel`` (optional)
|
||||
* ``voucher`` (optional, expect a voucher code)
|
||||
* ``addons`` (optional, expect a list of nested objects of cart positions)
|
||||
* ``bundled`` (optional, expect a list of nested objects of cart positions)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
@@ -221,6 +232,12 @@ Cart position endpoints
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"addons": [
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
}
|
||||
],
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
@@ -232,7 +249,7 @@ Cart position endpoints
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full cart position resource, see above.)
|
||||
(Full cart position resource, see above, with additional nested objects "addons" and "bundled".)
|
||||
|
||||
: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
|
||||
@@ -244,8 +261,8 @@ Cart position endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/bulk_create/
|
||||
|
||||
Creates multiple new cart position. This operation is deliberately not atomic, so each cart position can succeed
|
||||
or fail individually, so the response code of the response is not the only thing to look at!
|
||||
Creates multiple new cart position. **This operation is deliberately not atomic, so each cart position can succeed
|
||||
or fail individually, so the response code of the response is not the only thing to look at!**
|
||||
|
||||
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
|
||||
|
||||
|
||||
@@ -1925,7 +1925,7 @@ otherwise, such as splitting an order or changing fees.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/change/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -60,7 +60,13 @@ The exporter class
|
||||
.. py:attribute:: BaseExporter.event
|
||||
|
||||
The default constructor sets this property to the event we are currently
|
||||
working for.
|
||||
working for. This will be ``None`` if the exporter is run for multiple
|
||||
events.
|
||||
|
||||
.. py:attribute:: BaseExporter.events
|
||||
|
||||
The default constructor sets this property to the list of events to work
|
||||
on, regardless of whether the exporter is called for one or multiple events.
|
||||
|
||||
.. autoattribute:: identifier
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Frontend
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: order_info, order_info_top, order_meta_from_request
|
||||
:members: order_info, order_info_top, order_meta_from_request, order_source_from_request
|
||||
|
||||
Request flow
|
||||
""""""""""""
|
||||
|
||||
@@ -93,6 +93,7 @@ id integer Internal conten
|
||||
title multi-lingual string The content title (required)
|
||||
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
|
||||
url string The location of the digital content
|
||||
file file A downloadable file. Either ``url`` or ``file`` must be ``null``.
|
||||
description multi-lingual string A public description of the item. May contain Markdown
|
||||
syntax and is not required.
|
||||
available_from datetime The first date time at which this content will be shown
|
||||
@@ -144,6 +145,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -191,6 +193,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -229,6 +232,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -255,6 +259,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
@@ -309,6 +314,7 @@ API Endpoints
|
||||
},
|
||||
"content_type": "link",
|
||||
"url": "https://mywebsite.com",
|
||||
"file": null,
|
||||
"description": {
|
||||
"en": "Watch our event live here on YouTube!"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
sphinx==2.3.*
|
||||
jinja2==3.0.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-spelling==4.*
|
||||
sphinxemoji
|
||||
pygments-markdown-lexer
|
||||
# See https://github.com/rfk/pyenchant/pull/130
|
||||
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "4.13.0"
|
||||
__version__ = "4.14.1.dev0"
|
||||
|
||||
@@ -46,6 +46,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -80,6 +81,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -112,6 +114,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
@@ -145,6 +148,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:version'),
|
||||
('GET', 'api-v1:device.eventselection'),
|
||||
('GET', 'api-v1:idempotency.query'),
|
||||
('GET', 'api-v1:device.info'),
|
||||
('POST', 'api-v1:device.update'),
|
||||
('POST', 'api-v1:device.revoke'),
|
||||
('POST', 'api-v1:device.roll'),
|
||||
|
||||
+180
-137
@@ -23,8 +23,7 @@ import os
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
from rest_framework import serializers
|
||||
@@ -34,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import (
|
||||
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
|
||||
)
|
||||
from pretix.base.models import Quota, Seat, Voucher
|
||||
from pretix.base.models import Seat, Voucher
|
||||
from pretix.base.models.orders import CartPosition
|
||||
|
||||
|
||||
@@ -52,148 +51,18 @@ class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers', 'seat')
|
||||
'answers', 'seat', 'is_bundled')
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
class BaseCartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
includes_tax = serializers.BooleanField(required=False, allow_null=True)
|
||||
voucher = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers', 'seat', 'sales_channel', 'voucher')
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(validated_data.get('item'))
|
||||
)
|
||||
)
|
||||
for quota in new_quotas:
|
||||
avail = quota.availability(_cache=self.context['quota_cache'])
|
||||
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
|
||||
raise ValidationError(
|
||||
gettext_lazy('There is not enough quota available on quota "{}" to perform '
|
||||
'the operation.').format(
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
|
||||
for quota in new_quotas:
|
||||
oldsize = self.context['quota_cache'][quota.pk][1]
|
||||
newsize = oldsize - 1 if oldsize is not None else None
|
||||
self.context['quota_cache'][quota.pk] = (
|
||||
Quota.AVAILABILITY_OK if newsize is None or newsize > 0 else Quota.AVAILABILITY_GONE,
|
||||
newsize
|
||||
)
|
||||
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
|
||||
if validated_data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError('The specified product does not allow to choose a seat.')
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError('The specified seat does not exist.')
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError('The specified seat ID is not unique.')
|
||||
else:
|
||||
validated_data['seat'] = seat
|
||||
elif seated:
|
||||
raise ValidationError('The specified product requires to choose a seat.')
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
try:
|
||||
voucher = self.context['event'].vouchers.get(code__iexact=validated_data.get('voucher'))
|
||||
except Voucher.DoesNotExist:
|
||||
raise ValidationError('The specified voucher does not exist.')
|
||||
|
||||
if voucher and not voucher.applies_to(validated_data.get('item'), validated_data.get('variation')):
|
||||
raise ValidationError('The specified voucher is not valid for the given item and variation.')
|
||||
|
||||
if voucher and voucher.seat and voucher.seat != validated_data.get('seat'):
|
||||
raise ValidationError('The specified voucher is not valid for this seat.')
|
||||
|
||||
if voucher and voucher.subevent_id and (not validated_data.get('subevent') or voucher.subevent_id != validated_data['subevent'].pk):
|
||||
raise ValidationError('The specified voucher is not valid for this subevent.')
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now():
|
||||
raise ValidationError('The specified voucher is expired.')
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.context['event']) & Q(expires__gte=now())
|
||||
)
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if v_avail < 1:
|
||||
raise ValidationError('The specified voucher has already been used the maximum number of times.')
|
||||
|
||||
validated_data['voucher'] = voucher
|
||||
|
||||
if validated_data.get('seat'):
|
||||
if not validated_data['seat'].is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
||||
):
|
||||
raise ValidationError(
|
||||
gettext_lazy('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name))
|
||||
|
||||
validated_data.pop('sales_channel')
|
||||
# todo: does this make sense?
|
||||
validated_data['custom_price_input'] = validated_data['price']
|
||||
# todo: listed price, etc?
|
||||
# currently does not matter because there is no way to transform an API cart position into an order that keeps
|
||||
# prices, cart positions are just quota/voucher placeholders
|
||||
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
an.close()
|
||||
else:
|
||||
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.')
|
||||
return cid
|
||||
fields = ('item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'includes_tax', 'answers')
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
@@ -240,4 +109,178 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
|
||||
if not data.get('expires'):
|
||||
data['expires'] = now() + timedelta(
|
||||
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
|
||||
)
|
||||
|
||||
quotas_for_item_cache = self.context.get('quotas_for_item_cache', {})
|
||||
quotas_for_variation_cache = self.context.get('quotas_for_variation_cache', {})
|
||||
|
||||
seated = data.get('item').seat_category_mappings.filter(subevent=data.get('subevent')).exists()
|
||||
if data.get('seat'):
|
||||
if not seated:
|
||||
raise ValidationError({'seat': ['The specified product does not allow to choose a seat.']})
|
||||
try:
|
||||
seat = self.context['event'].seats.get(seat_guid=data['seat'], subevent=data.get('subevent'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError({'seat': ['The specified seat does not exist.']})
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError({'seat': ['The specified seat ID is not unique.']})
|
||||
else:
|
||||
data['seat'] = seat
|
||||
elif seated:
|
||||
raise ValidationError({'seat': ['The specified product requires to choose a seat.']})
|
||||
|
||||
if data.get('voucher'):
|
||||
try:
|
||||
voucher = self.context['event'].vouchers.get(code__iexact=data['voucher'])
|
||||
except Voucher.DoesNotExist:
|
||||
raise ValidationError({'voucher': ['The specified voucher does not exist.']})
|
||||
|
||||
if voucher and not voucher.applies_to(data['item'], data.get('variation')):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for the given item and variation.']})
|
||||
|
||||
if voucher and voucher.seat and voucher.seat != data.get('seat'):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for this seat.']})
|
||||
|
||||
if voucher and voucher.subevent_id and (not data.get('subevent') or voucher.subevent_id != data['subevent'].pk):
|
||||
raise ValidationError({'voucher': ['The specified voucher is not valid for this subevent.']})
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now():
|
||||
raise ValidationError({'voucher': ['The specified voucher is expired.']})
|
||||
|
||||
data['voucher'] = voucher
|
||||
|
||||
if not data.get('voucher') or (not data['voucher'].allow_ignore_quota and not data['voucher'].block_quota):
|
||||
if data.get('variation'):
|
||||
if data['variation'].pk not in quotas_for_variation_cache:
|
||||
quotas_for_variation_cache[data['variation'].pk] = data['variation'].quotas.filter(subevent=data.get('subevent'))
|
||||
data['_quotas'] = quotas_for_variation_cache[data['variation'].pk]
|
||||
else:
|
||||
if data['item'].pk not in quotas_for_item_cache:
|
||||
quotas_for_item_cache[data['item'].pk] = data['item'].quotas.filter(subevent=data.get('subevent'))
|
||||
data['_quotas'] = quotas_for_item_cache[data['item'].pk]
|
||||
|
||||
if len(data['_quotas']) == 0:
|
||||
raise ValidationError(
|
||||
gettext_lazy('The product "{}" is not assigned to a quota.').format(
|
||||
str(data.get('item'))
|
||||
)
|
||||
)
|
||||
else:
|
||||
data['_quotas'] = []
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('_quotas')
|
||||
answers_data = validated_data.pop('answers')
|
||||
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
|
||||
# todo: does this make sense?
|
||||
validated_data['custom_price_input'] = validated_data['price']
|
||||
# todo: listed price, etc?
|
||||
# currently does not matter because there is no way to transform an API cart position into an order that keeps
|
||||
# prices, cart positions are just quota/voucher placeholders
|
||||
validated_data['custom_price_input_is_net'] = not validated_data.pop('includes_tax', True)
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(os.path.basename(an.name), an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
an.close()
|
||||
else:
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
addons = BaseCartPositionCreateSerializer(many=True, required=False)
|
||||
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
|
||||
seat = serializers.CharField(required=False, allow_null=True)
|
||||
sales_channel = serializers.CharField(required=False, default='sales_channel')
|
||||
voucher = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = BaseCartPositionCreateSerializer.Meta.fields + (
|
||||
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
|
||||
)
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
if cid and not cid.endswith('@api'):
|
||||
raise ValidationError('Cart ID should end in @api or be empty.')
|
||||
return cid
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('sales_channel')
|
||||
addons_data = validated_data.pop('addons', None)
|
||||
bundled_data = validated_data.pop('bundled', None)
|
||||
|
||||
cp = super().create(validated_data)
|
||||
|
||||
if addons_data:
|
||||
for addon_data in addons_data:
|
||||
addon_data['addon_to'] = cp
|
||||
addon_data['is_bundled'] = False
|
||||
super().create(addon_data)
|
||||
|
||||
if bundled_data:
|
||||
for bundle_data in bundled_data:
|
||||
bundle_data['addon_to'] = cp
|
||||
bundle_data['is_bundled'] = True
|
||||
super().create(bundle_data)
|
||||
|
||||
return cp
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
# This is currently only a very basic validation of add-ons and bundled products, we don't validate their number
|
||||
# or price. We can always go stricter, as the endpoint is documented as experimental.
|
||||
# However, this serializer should always be *at least* as strict as the order creation serializer.
|
||||
|
||||
if data.get('item') and data.get('addons'):
|
||||
prefetch_related_objects([data['item']], 'addons')
|
||||
for sub_data in data['addons']:
|
||||
if not any(a.addon_category_id == sub_data['item'].category_id for a in data['item'].addons.all()):
|
||||
raise ValidationError({
|
||||
'addons': [
|
||||
'The product "{prod}" can not be used as an add-on product for "{main}".'.format(
|
||||
prod=str(sub_data['item']),
|
||||
main=str(data['item']),
|
||||
)
|
||||
]
|
||||
})
|
||||
|
||||
if data.get('item') and data.get('bundled'):
|
||||
prefetch_related_objects([data['item']], 'bundles')
|
||||
for sub_data in data['bundled']:
|
||||
if not any(
|
||||
a.bundled_item_id == sub_data['item'].pk and
|
||||
a.bundled_variation_id == (sub_data['variation'].pk if sub_data.get('variation') else None)
|
||||
for a in data['item'].bundles.all()
|
||||
):
|
||||
raise ValidationError({
|
||||
'bundled': [
|
||||
'The product "{prod}" can not be used as an bundled product for "{main}".'.format(
|
||||
prod=str(sub_data['item']),
|
||||
main=str(data['item']),
|
||||
)
|
||||
]
|
||||
})
|
||||
return data
|
||||
|
||||
@@ -23,6 +23,8 @@ from django import forms
|
||||
from django.http import QueryDict
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -49,7 +51,6 @@ simple_mappings = (
|
||||
(forms.EmailField, serializers.EmailField, ()),
|
||||
(forms.UUIDField, serializers.UUIDField, ()),
|
||||
(forms.URLField, serializers.URLField, ()),
|
||||
(forms.NullBooleanField, serializers.NullBooleanField, ()),
|
||||
(forms.BooleanField, serializers.BooleanField, ()),
|
||||
)
|
||||
|
||||
@@ -87,7 +88,7 @@ class JobRunSerializer(serializers.Serializer):
|
||||
ex = kwargs.pop('exporter')
|
||||
events = kwargs.pop('events', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if events is not None:
|
||||
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
|
||||
self.fields["events"] = serializers.SlugRelatedField(
|
||||
queryset=events,
|
||||
required=True,
|
||||
@@ -106,6 +107,12 @@ class JobRunSerializer(serializers.Serializer):
|
||||
)
|
||||
break
|
||||
|
||||
if isinstance(v, forms.NullBooleanField):
|
||||
self.fields[k] = serializers.BooleanField(
|
||||
required=v.required,
|
||||
allow_null=True,
|
||||
validators=v.validators,
|
||||
)
|
||||
if isinstance(v, forms.ModelMultipleChoiceField):
|
||||
self.fields[k] = PrimaryKeyRelatedField(
|
||||
queryset=v.queryset,
|
||||
|
||||
@@ -1086,6 +1086,10 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
|
||||
if pos_data.get('seat'):
|
||||
if pos_data.get('addon_to'):
|
||||
errs[i]['seat'] = ['Seats are currently not supported for add-on products.']
|
||||
continue
|
||||
|
||||
if not seated:
|
||||
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
|
||||
try:
|
||||
@@ -1281,6 +1285,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
if cp.addon_to_id:
|
||||
continue
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
order.total = sum([p.price for p in pos_map.values()])
|
||||
@@ -1371,7 +1378,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
|
||||
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values(), source=self.context['source'])
|
||||
return order
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Device, TeamAPIToken
|
||||
|
||||
|
||||
def get_api_source(request):
|
||||
if isinstance(request.auth, Device):
|
||||
return "pretix.api", f"device:{request.auth.pk}"
|
||||
elif isinstance(request.auth, TeamAPIToken):
|
||||
return "pretix.api", f"token:{request.auth.pk}"
|
||||
elif isinstance(request.auth, OAuthAccessToken):
|
||||
return "pretix.api", f"oauth.app:{request.auth.application.pk}"
|
||||
elif request.user.is_authenticated:
|
||||
return "pretix.api", f"user:{request.user.pk}"
|
||||
return "pretix.api", None
|
||||
+167
-43
@@ -19,19 +19,28 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from collections import Counter
|
||||
from typing import List
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.serializers import as_serializer_error
|
||||
|
||||
from pretix.api.serializers.cart import (
|
||||
CartPositionCreateSerializer, CartPositionSerializer,
|
||||
)
|
||||
from pretix.base.models import CartPosition
|
||||
from pretix.base.services.cart import (
|
||||
_get_quota_availability, _get_voucher_availability, error_messages,
|
||||
)
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
|
||||
|
||||
@@ -54,18 +63,17 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['quota_cache'] = {}
|
||||
ctx['quotas_for_item_cache'] = {}
|
||||
ctx['quotas_for_variation_cache'] = {}
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
ctx = self.get_serializer_context()
|
||||
serializer = CartPositionCreateSerializer(data=request.data, context=ctx)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic(), self.request.event.lock():
|
||||
self.perform_create(serializer)
|
||||
cp = serializer.instance
|
||||
serializer = CartPositionSerializer(cp, context=serializer.context)
|
||||
results = self._create(serializers=[serializer], raise_exception=True, ctx=ctx)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
return Response(results[0]['data'], status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=False, methods=['POST'])
|
||||
def bulk_create(self, request, *args, **kwargs):
|
||||
@@ -73,42 +81,158 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
return Response({"error": "Please supply a list"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ctx = self.get_serializer_context()
|
||||
with transaction.atomic():
|
||||
serializers = [
|
||||
CartPositionCreateSerializer(data=d, context=ctx)
|
||||
for d in request.data
|
||||
]
|
||||
|
||||
lockfn = self.request.event.lock
|
||||
if not any(s.is_valid(raise_exception=False) for s in serializers):
|
||||
lockfn = NoLockManager
|
||||
|
||||
results = []
|
||||
with lockfn():
|
||||
for s in serializers:
|
||||
if s.is_valid(raise_exception=False):
|
||||
try:
|
||||
cp = s.save()
|
||||
except ValidationError as e:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': {api_settings.NON_FIELD_ERRORS_KEY: e.detail},
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'data': CartPositionSerializer(cp, context=ctx).data,
|
||||
'errors': None,
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': s.errors,
|
||||
})
|
||||
serializers = [
|
||||
CartPositionCreateSerializer(data=d, context=ctx)
|
||||
for d in request.data
|
||||
]
|
||||
|
||||
results = self._create(serializers=serializers, raise_exception=False, ctx=ctx)
|
||||
return Response({'results': results}, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
raise NotImplementedError()
|
||||
|
||||
def _require_locking(self, quota_diff, voucher_use_diff, seat_diff):
|
||||
if voucher_use_diff or seat_diff:
|
||||
# If any vouchers or seats are used, we lock to make sure we don't redeem them to often
|
||||
return True
|
||||
|
||||
if quota_diff and any(q.size is not None for q in quota_diff):
|
||||
# If any quotas are affected that are not unlimited, we lock
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def _create_default_cart_id(self):
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
while CartPosition.objects.filter(cart_id=cid).exists():
|
||||
cid = "{}@api".format(get_random_string(48))
|
||||
return cid
|
||||
|
||||
def _create(self, serializers: List[CartPositionCreateSerializer], ctx, raise_exception=False):
|
||||
voucher_use_diff = Counter()
|
||||
quota_diff = Counter()
|
||||
seat_diff = Counter()
|
||||
results = [{} for pserializer in serializers]
|
||||
|
||||
for i, pserializer in enumerate(serializers):
|
||||
if not pserializer.is_valid(raise_exception=raise_exception):
|
||||
results[i] = {
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': pserializer.errors,
|
||||
}
|
||||
|
||||
for pserializer in serializers:
|
||||
if pserializer.errors:
|
||||
continue
|
||||
|
||||
validated_data = pserializer.validated_data
|
||||
if not validated_data.get('cart_id'):
|
||||
validated_data['cart_id'] = self._create_default_cart_id
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
voucher_use_diff[validated_data['voucher']] += 1
|
||||
|
||||
if validated_data.get('seat'):
|
||||
seat_diff[validated_data['seat']] += 1
|
||||
|
||||
for q in validated_data['_quotas']:
|
||||
quota_diff[q] += 1
|
||||
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
||||
for q in sub_data['_quotas']:
|
||||
quota_diff[q] += 1
|
||||
|
||||
seats_seen = set()
|
||||
|
||||
lockfn = NoLockManager
|
||||
if self._require_locking(quota_diff, voucher_use_diff, seat_diff):
|
||||
lockfn = self.request.event.lock
|
||||
|
||||
with lockfn() as now_dt, transaction.atomic():
|
||||
vouchers_ok, vouchers_depend_on_cart = _get_voucher_availability(
|
||||
self.request.event,
|
||||
voucher_use_diff,
|
||||
now_dt,
|
||||
exclude_position_ids=[],
|
||||
)
|
||||
quotas_ok = _get_quota_availability(quota_diff, now_dt)
|
||||
|
||||
for i, pserializer in enumerate(serializers):
|
||||
if results[i]:
|
||||
continue
|
||||
|
||||
try:
|
||||
validated_data = pserializer.validated_data
|
||||
|
||||
if validated_data.get('seat'):
|
||||
# Assumption: Add-ons currently can't have seats
|
||||
if validated_data['seat'] in seats_seen:
|
||||
raise ValidationError(error_messages['seat_multiple'])
|
||||
seats_seen.add(validated_data['seat'])
|
||||
|
||||
quotas_needed = Counter()
|
||||
for q in validated_data['_quotas']:
|
||||
quotas_needed[q] += 1
|
||||
for sub_data in validated_data.get('addons', []) + validated_data.get('bundled', []):
|
||||
for q in sub_data['_quotas']:
|
||||
quotas_needed[q] += 1
|
||||
|
||||
for q, needed in quotas_needed.items():
|
||||
if quotas_ok[q] < needed:
|
||||
raise ValidationError(
|
||||
_('There is not enough quota available on quota "{}" to perform the operation.').format(
|
||||
q.name
|
||||
)
|
||||
)
|
||||
|
||||
if validated_data.get('voucher'):
|
||||
# Assumption: Add-ons currently can't have vouchers, thus we only need to check the main voucher
|
||||
if vouchers_ok[validated_data['voucher']] < 1:
|
||||
raise ValidationError(
|
||||
{'voucher': [_('The specified voucher has already been used the maximum number of times.')]}
|
||||
)
|
||||
|
||||
if validated_data.get('seat'):
|
||||
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
|
||||
if not validated_data['seat'].is_available(
|
||||
sales_channel=validated_data.get('sales_channel', 'web'),
|
||||
distance_ignore_cart_id=validated_data['cart_id'],
|
||||
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
|
||||
):
|
||||
raise ValidationError(
|
||||
{'seat': [_('The selected seat "{seat}" is not available.').format(seat=validated_data['seat'].name)]}
|
||||
)
|
||||
|
||||
for q, needed in quotas_needed.items():
|
||||
quotas_ok[q] -= needed
|
||||
if validated_data.get('voucher'):
|
||||
vouchers_ok[validated_data['voucher']] -= 1
|
||||
|
||||
if any(qa < 0 for qa in quotas_ok.values()):
|
||||
# Safeguard, should never happen because of conditions above
|
||||
raise ValidationError(error_messages['unavailable'])
|
||||
|
||||
cp = pserializer.create(validated_data)
|
||||
|
||||
d = CartPositionSerializer(cp, context=ctx).data
|
||||
addons = sorted(cp.addons.all(), key=lambda a: a.pk) # order of creation, safe since they are created in the same transaction
|
||||
d['addons'] = CartPositionSerializer([a for a in addons if not a.is_bundled], many=True, context=ctx).data
|
||||
d['bundled'] = CartPositionSerializer([a for a in addons if a.is_bundled], many=True, context=ctx).data
|
||||
|
||||
results[i] = {
|
||||
'success': True,
|
||||
'data': d,
|
||||
'errors': None,
|
||||
}
|
||||
except ValidationError as e:
|
||||
if raise_exception:
|
||||
raise
|
||||
results[i] = {
|
||||
'success': False,
|
||||
'data': None,
|
||||
'errors': as_serializer_error(e),
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
@@ -35,7 +35,8 @@ from rest_framework.reverse import reverse
|
||||
from pretix.api.serializers.exporters import (
|
||||
ExporterSerializer, JobRunSerializer,
|
||||
)
|
||||
from pretix.base.models import CachedFile, Device, TeamAPIToken
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.models import CachedFile, Device, Event, TeamAPIToken
|
||||
from pretix.base.services.export import export, multiexport
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
@@ -155,7 +156,19 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
raw_exporters = [
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
|
||||
for r, response in responses
|
||||
if response
|
||||
]
|
||||
raw_exporters = [
|
||||
ex for ex in raw_exporters
|
||||
if (
|
||||
not isinstance(ex, OrganizerLevelExportMixin) or
|
||||
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
|
||||
)
|
||||
]
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
@@ -61,6 +61,7 @@ from pretix.api.serializers.orderchange import (
|
||||
OrderPositionCreateForExistingOrderSerializer,
|
||||
OrderPositionInfoPatchSerializer,
|
||||
)
|
||||
from pretix.api.utils import get_api_source
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
|
||||
@@ -190,6 +191,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
|
||||
ctx['exclude'] = self.request.query_params.getlist('exclude')
|
||||
ctx['source'] = get_api_source(self.request)
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -390,7 +392,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail,
|
||||
email_comment=comment,
|
||||
cancellation_fee=cancellation_fee
|
||||
cancellation_fee=cancellation_fee,
|
||||
source=get_api_source(request),
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
@@ -414,6 +417,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
source=get_api_source(request),
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
@@ -433,6 +437,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
source=get_api_source(request),
|
||||
)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -453,6 +458,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
comment=comment,
|
||||
source=get_api_source(request),
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -491,6 +497,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth,
|
||||
source=get_api_source(request),
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@@ -508,6 +515,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
|
||||
source=get_api_source(request),
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@@ -1484,7 +1492,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
if mark_refunded:
|
||||
mark_order_refunded(payment.order,
|
||||
user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
auth=self.request.auth,
|
||||
source=get_api_source(self.request))
|
||||
else:
|
||||
payment.order.status = Order.STATUS_PENDING
|
||||
payment.order.set_expires(
|
||||
@@ -1557,7 +1566,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
auth=self.request.auth, source=get_api_source(self.request))
|
||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||
refund.order.status = Order.STATUS_PENDING
|
||||
refund.order.set_expires(
|
||||
@@ -1610,6 +1619,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
r.order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=(request.auth if request.auth else None),
|
||||
source=get_api_source(self.request),
|
||||
)
|
||||
except OrderError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
@@ -51,7 +51,7 @@ from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for
|
||||
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||
)
|
||||
|
||||
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
|
||||
__ = excel_safe # just so the compatibility import above is "used" and doesn't get removed by linter
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
@@ -80,7 +80,7 @@ class BaseExporter:
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this exporter. This should be short but
|
||||
self-explaining. Good examples include 'JSON' or 'Microsoft Excel'.
|
||||
self-explaining. Good examples include 'Orders as JSON' or 'Orders as Microsoft Excel'.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@@ -137,6 +137,16 @@ class BaseExporter:
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
class OrganizerLevelExportMixin:
|
||||
@property
|
||||
def organizer_required_permission(self) -> str:
|
||||
"""
|
||||
The permission level required to use this exporter. Only useful for organizer-level exports,
|
||||
not for event-level exports.
|
||||
"""
|
||||
return 'can_view_orders'
|
||||
|
||||
|
||||
class ListExporter(BaseExporter):
|
||||
ProgressSetTotal = namedtuple('ProgressSetTotal', 'total')
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from .answers import * # noqa
|
||||
from .customers import * # noqa
|
||||
from .dekodi import * # noqa
|
||||
from .events import * # noqa
|
||||
from .invoices import * # noqa
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
|
||||
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
#
|
||||
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
|
||||
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
|
||||
#
|
||||
# This file contains Apache-licensed contributions copyrighted by: Benjamin Hättasch, Tobias Kunze
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.translation import gettext as _, gettext_lazy
|
||||
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import ListExporter, OrganizerLevelExportMixin
|
||||
from ..signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
organizer_required_permission = 'can_manage_customers'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = self.organizer.customers.prefetch_related('provider')
|
||||
|
||||
headers = [
|
||||
_('Customer ID'),
|
||||
_('SSO provider'),
|
||||
_('External identifier'),
|
||||
_('E-mail'),
|
||||
_('Phone number'),
|
||||
_('Full name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Name') + ': ' + str(label))
|
||||
|
||||
headers += [
|
||||
_('Account active'),
|
||||
_('Verified email address'),
|
||||
_('Last login'),
|
||||
_('Registration date'),
|
||||
_('Language'),
|
||||
_('Notes'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.identifier,
|
||||
obj.provider.name if obj.provider else None,
|
||||
obj.external_identifier,
|
||||
obj.email or '',
|
||||
obj.phone or '',
|
||||
obj.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(obj.name_parts.get(k, ''))
|
||||
row += [
|
||||
_('Yes') if obj.is_active else _('No'),
|
||||
_('Yes') if obj.is_verified else _('No'),
|
||||
obj.last_login.astimezone(tz).date().strftime('%Y-%m-%d') if obj.last_login else '',
|
||||
obj.date_joined.astimezone(tz).date().strftime('%Y-%m-%d') if obj.date_joined else '',
|
||||
obj.get_locale_display(),
|
||||
obj.notes or '',
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_customers'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_customerlist")
|
||||
def register_multievent_i_customerlist_exporter(sender, **kwargs):
|
||||
return CustomerListExporter
|
||||
@@ -60,7 +60,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
@@ -884,76 +886,75 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
def generate_GiftCardTransactionListExporter(organizer): # hackhack
|
||||
class GiftcardTransactionListExporter(ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
return d
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=self.organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode'),
|
||||
_('Date'),
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.card.secret,
|
||||
_('TEST MODE') if obj.card.testmode else '',
|
||||
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
return d
|
||||
yield row
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
qs = GiftCardTransaction.objects.filter(
|
||||
card__issuer=organizer,
|
||||
).order_by('datetime').select_related('card', 'order', 'order__event')
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
qs = qs.filter(
|
||||
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
)
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if isinstance(date_value, str):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
|
||||
qs = qs.filter(
|
||||
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode'),
|
||||
_('Date'),
|
||||
_('Amount'),
|
||||
_('Currency'),
|
||||
_('Order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in qs:
|
||||
row = [
|
||||
obj.card.secret,
|
||||
_('TEST MODE') if obj.card.testmode else '',
|
||||
obj.datetime.astimezone(self.timezone).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.value,
|
||||
obj.card.currency,
|
||||
obj.order.full_code if obj.order else None,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcardtransactions'.format(organizer.slug)
|
||||
return GiftcardTransactionListExporter
|
||||
def get_filename(self):
|
||||
return '{}_giftcardtransactions'.format(self.organizer.slug)
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
@@ -1000,114 +1001,112 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
def generate_GiftCardListExporter(organizer): # hackhack
|
||||
class GiftcardListExporter(ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('yes', _('Test mode')),
|
||||
('no', _('Live')),
|
||||
),
|
||||
initial='no',
|
||||
required=False
|
||||
)),
|
||||
('state', forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('empty', _('Empty')),
|
||||
('valid_value', _('Valid and with value')),
|
||||
('expired_value', _('Expired and with value')),
|
||||
('expired', _('Expired')),
|
||||
),
|
||||
initial='valid_value',
|
||||
required=False
|
||||
))
|
||||
]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
|
||||
)
|
||||
|
||||
if form_data.get('testmode') == 'yes':
|
||||
qs = qs.filter(testmode=True)
|
||||
elif form_data.get('testmode') == 'no':
|
||||
qs = qs.filter(testmode=False)
|
||||
|
||||
if form_data.get('state') == 'empty':
|
||||
qs = qs.filter(cached_value=0)
|
||||
elif form_data.get('state') == 'valid_value':
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode card'),
|
||||
_('Creation date'),
|
||||
_('Expiry date'),
|
||||
_('Special terms and conditions'),
|
||||
_('Currency'),
|
||||
_('Current value'),
|
||||
_('Created in order'),
|
||||
_('Last invoice number of order'),
|
||||
_('Last invoice date of order'),
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('yes', _('Test mode')),
|
||||
('no', _('Live')),
|
||||
),
|
||||
initial='no',
|
||||
required=False
|
||||
)),
|
||||
('state', forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('empty', _('Empty')),
|
||||
('valid_value', _('Valid and with value')),
|
||||
('expired_value', _('Expired and with value')),
|
||||
('expired', _('Expired')),
|
||||
),
|
||||
initial='valid_value',
|
||||
required=False
|
||||
))
|
||||
]
|
||||
yield headers
|
||||
)
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
o = None
|
||||
i = None
|
||||
trans = list(obj.transactions.all())
|
||||
if trans:
|
||||
o = trans[0].order
|
||||
if o:
|
||||
invs = list(o.invoices.all())
|
||||
if invs:
|
||||
i = invs[-1]
|
||||
row = [
|
||||
obj.secret,
|
||||
_('Yes') if obj.testmode else _('No'),
|
||||
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
|
||||
obj.conditions or '',
|
||||
obj.currency,
|
||||
obj.cached_value,
|
||||
o.full_code if o else '',
|
||||
i.number if i else '',
|
||||
i.date.strftime('%Y-%m-%d') if i else '',
|
||||
]
|
||||
yield row
|
||||
def iterate_list(self, form_data):
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = self.organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
'transactions', 'transactions__order', 'transactions__order__event', 'transactions__order__invoices'
|
||||
)
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcards'.format(organizer.slug)
|
||||
if form_data.get('testmode') == 'yes':
|
||||
qs = qs.filter(testmode=True)
|
||||
elif form_data.get('testmode') == 'no':
|
||||
qs = qs.filter(testmode=False)
|
||||
|
||||
return GiftcardListExporter
|
||||
if form_data.get('state') == 'empty':
|
||||
qs = qs.filter(cached_value=0)
|
||||
elif form_data.get('state') == 'valid_value':
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
_('Test mode card'),
|
||||
_('Creation date'),
|
||||
_('Expiry date'),
|
||||
_('Special terms and conditions'),
|
||||
_('Currency'),
|
||||
_('Current value'),
|
||||
_('Created in order'),
|
||||
_('Last invoice number of order'),
|
||||
_('Last invoice date of order'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
tz = get_current_timezone()
|
||||
for obj in qs:
|
||||
o = None
|
||||
i = None
|
||||
trans = list(obj.transactions.all())
|
||||
if trans:
|
||||
o = trans[0].order
|
||||
if o:
|
||||
invs = list(o.invoices.all())
|
||||
if invs:
|
||||
i = invs[-1]
|
||||
row = [
|
||||
obj.secret,
|
||||
_('Yes') if obj.testmode else _('No'),
|
||||
obj.issuance.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
obj.expires.astimezone(tz).date().strftime('%Y-%m-%d') if obj.expires else '',
|
||||
obj.conditions or '',
|
||||
obj.currency,
|
||||
obj.cached_value,
|
||||
o.full_code if o else '',
|
||||
i.number if i else '',
|
||||
i.date.strftime('%Y-%m-%d') if i else '',
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_giftcards'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
@@ -1147,9 +1146,9 @@ def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardlist")
|
||||
def register_multievent_i_giftcardlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardListExporter(sender)
|
||||
return GiftcardListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardtransactionlist")
|
||||
def register_multievent_i_giftcardtransactionlist_exporter(sender, **kwargs):
|
||||
return generate_GiftCardTransactionListExporter(sender)
|
||||
return GiftcardTransactionListExporter
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.2 on 2022-10-19 09:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0222_alter_question_unique_together'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='source_identifier',
|
||||
field=models.CharField(db_index=True, max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='source_type',
|
||||
field=models.CharField(db_index=True, max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
@@ -78,6 +78,7 @@ class Customer(LoggedModel):
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
provider = models.ForeignKey(CustomerSSOProvider, related_name='customers', on_delete=models.PROTECT, null=True, blank=True)
|
||||
identifier = models.CharField(
|
||||
verbose_name=_('Customer ID'),
|
||||
max_length=190,
|
||||
db_index=True,
|
||||
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
|
||||
|
||||
@@ -1041,10 +1041,13 @@ class Order(LockModel, LoggedModel):
|
||||
continue
|
||||
yield op
|
||||
|
||||
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
|
||||
_backfill_before_cancellation=False, save=True):
|
||||
def create_transactions(self, *, source=None, is_new=False, positions=None, fees=None,
|
||||
dt_now=None, migrated=False, _backfill_before_cancellation=False, save=True):
|
||||
dt_now = dt_now or now()
|
||||
|
||||
if source is not None and (not isinstance(source, tuple) or len(source) != 2 or not all(isinstance(a, str) or a is None for a in source)):
|
||||
return ValueError("source needs to be a 2-tuple of (source_type(str), source_identifier(str))")
|
||||
|
||||
# Count the transactions we already have
|
||||
current_transaction_count = Counter()
|
||||
if not is_new:
|
||||
@@ -1089,6 +1092,8 @@ class Order(LockModel, LoggedModel):
|
||||
tax_value=taxvalue,
|
||||
fee_type=feetype,
|
||||
internal_type=internaltype,
|
||||
source_type=source[0] if source else None,
|
||||
source_identifier=source[1] if source else None,
|
||||
))
|
||||
create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0))
|
||||
if save:
|
||||
@@ -1573,7 +1578,7 @@ class OrderPayment(models.Model):
|
||||
return self.order.event.get_payment_providers(cached=True).get(self.provider)
|
||||
|
||||
@transaction.atomic()
|
||||
def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
|
||||
def _mark_paid_inner(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False, source=None):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
|
||||
if can_be_paid is not True:
|
||||
@@ -1596,7 +1601,9 @@ class OrderPayment(models.Model):
|
||||
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
if status_change:
|
||||
self.order.create_transactions()
|
||||
self.order.create_transactions(
|
||||
source=source or ('pretix.payment', None),
|
||||
)
|
||||
|
||||
def fail(self, info=None, user=None, auth=None, log_data=None):
|
||||
"""
|
||||
@@ -1630,7 +1637,7 @@ class OrderPayment(models.Model):
|
||||
}, user=user, auth=auth)
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_date=None):
|
||||
ignore_date=False, lock=True, payment_date=None, source=None):
|
||||
"""
|
||||
Marks the payment as complete. If possible, this also marks the order as paid if no further
|
||||
payment is required
|
||||
@@ -1693,10 +1700,11 @@ class OrderPayment(models.Model):
|
||||
))
|
||||
return
|
||||
|
||||
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum)
|
||||
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum,
|
||||
source)
|
||||
|
||||
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
|
||||
ignore_date=False, lock=True, payment_refund_sum=0):
|
||||
ignore_date=False, lock=True, payment_refund_sum=0, source=None):
|
||||
from pretix.base.services.invoices import (
|
||||
generate_invoice, invoice_qualified,
|
||||
)
|
||||
@@ -1710,7 +1718,7 @@ class OrderPayment(models.Model):
|
||||
|
||||
with lockfn():
|
||||
self._mark_paid_inner(force, count_waitinglist, user, auth, overpaid=payment_refund_sum > self.order.total,
|
||||
ignore_date=ignore_date)
|
||||
ignore_date=ignore_date, source=source)
|
||||
|
||||
invoice = None
|
||||
if invoice_qualified(self.order):
|
||||
@@ -2237,7 +2245,7 @@ class OrderPosition(AbstractPosition):
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
|
||||
|
||||
@property
|
||||
def checkins(self):
|
||||
@@ -2263,7 +2271,7 @@ class OrderPosition(AbstractPosition):
|
||||
ops = []
|
||||
cp_mapping = {}
|
||||
# The sorting key ensures that all addons come directly after the position they refer to
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: c.sort_key)):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
if f.name == 'addon_to':
|
||||
@@ -2483,6 +2491,8 @@ class Transaction(models.Model):
|
||||
|
||||
:param id: ID of the transaction
|
||||
:param order: Order the transaction belongs to
|
||||
:param source_type: Functionality that caused the transaction to be created, usually the name of a module or plugin
|
||||
:param source_identifier: Identifier of the entity that caused the transaction to be created, as defined by the module or plugin noted in ``source_type``.
|
||||
:param datetime: Date and time of the transaction
|
||||
:param migrated: Whether this object was reconstructed because the order was created before transactions where introduced
|
||||
:param positionid: Affected Position ID, in case this transaction represents a change in an order position
|
||||
@@ -2505,6 +2515,12 @@ class Transaction(models.Model):
|
||||
related_name='transactions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
source_type = models.CharField(
|
||||
max_length=190, db_index=True, null=True, blank=True
|
||||
)
|
||||
source_identifier = models.CharField(
|
||||
max_length=190, db_index=True, null=True, blank=True
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
@@ -2659,6 +2675,20 @@ class CartPosition(AbstractPosition):
|
||||
self.event.currency)
|
||||
return self.price - net
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
subevent_key = (self.subevent.date_from, str(self.subevent.name), self.subevent_id) if self.subevent_id else (0, "", 0)
|
||||
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
|
||||
item_key = self.item.position, self.item_id
|
||||
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
|
||||
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
|
||||
sort_key = subevent_key + category_key + item_key + variation_key + line_key
|
||||
|
||||
if self.addon_to_id:
|
||||
return self.addon_to.sort_key + (1 if self.is_bundled else 2,) + sort_key
|
||||
else:
|
||||
return sort_key
|
||||
|
||||
def update_listed_price_and_voucher(self, voucher_only=False, max_discount=None):
|
||||
from pretix.base.services.pricing import (
|
||||
get_listed_price, is_included_for_free,
|
||||
@@ -2714,6 +2744,11 @@ class CartPosition(AbstractPosition):
|
||||
self.tax_rate = line_price.rate
|
||||
self.save(update_fields=['line_price_gross', 'tax_rate'])
|
||||
|
||||
@property
|
||||
def addons_without_bundled(self):
|
||||
addons = [op for op in self.addons.all() if not op.is_bundled]
|
||||
return sorted(addons, key=lambda cp: cp.sort_key)
|
||||
|
||||
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
+30
-2
@@ -891,7 +891,11 @@ class Renderer:
|
||||
elif o['type'] == "poweredby":
|
||||
self._draw_poweredby(canvas, op, o)
|
||||
if self.bg_pdf:
|
||||
canvas.setPageSize((self.bg_pdf.pages[0].mediabox[2], self.bg_pdf.pages[0].mediabox[3]))
|
||||
page_size = (self.bg_pdf.pages[0].mediabox[2], self.bg_pdf.pages[0].mediabox[3])
|
||||
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
|
||||
# swap dimensions due to pdf being rotated
|
||||
page_size = page_size[::-1]
|
||||
canvas.setPageSize(page_size)
|
||||
if show_page:
|
||||
canvas.showPage()
|
||||
|
||||
@@ -915,13 +919,37 @@ class Renderer:
|
||||
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
|
||||
return BytesIO(f.read())
|
||||
else:
|
||||
from PyPDF2 import PdfReader, PdfWriter
|
||||
from PyPDF2 import PdfReader, PdfWriter, Transformation
|
||||
from PyPDF2.generic import RectangleObject
|
||||
buffer.seek(0)
|
||||
new_pdf = PdfReader(buffer)
|
||||
output = PdfWriter()
|
||||
|
||||
for i, page in enumerate(new_pdf.pages):
|
||||
bg_page = copy.copy(self.bg_pdf.pages[i])
|
||||
bg_rotation = bg_page.get('/Rotate')
|
||||
if bg_rotation:
|
||||
# /Rotate is clockwise, transformation.rotate is counter-clockwise
|
||||
t = Transformation().rotate(bg_rotation)
|
||||
w = float(page.mediabox.getWidth())
|
||||
h = float(page.mediabox.getHeight())
|
||||
if bg_rotation in (90, 270):
|
||||
# offset due to rotation base
|
||||
if bg_rotation == 90:
|
||||
t = t.translate(h, 0)
|
||||
else:
|
||||
t = t.translate(0, w)
|
||||
# rotate mediabox as well
|
||||
page.mediabox = RectangleObject((
|
||||
page.mediabox.left.as_numeric(),
|
||||
page.mediabox.bottom.as_numeric(),
|
||||
page.mediabox.top.as_numeric(),
|
||||
page.mediabox.right.as_numeric(),
|
||||
))
|
||||
page.trimbox = page.mediabox
|
||||
elif bg_rotation == 180:
|
||||
t = t.translate(w, h)
|
||||
page.add_transformation(t)
|
||||
bg_page.merge_page(page)
|
||||
output.add_page(bg_page)
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: pretix_sig1.proto
|
||||
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import (
|
||||
descriptor as _descriptor, message as _message, reflection as _reflection,
|
||||
descriptor as _descriptor, descriptor_pool as _descriptor_pool,
|
||||
symbol_database as _symbol_database,
|
||||
)
|
||||
from google.protobuf.internal import builder as _builder
|
||||
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
@@ -36,80 +37,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||
name='pretix_sig1.proto',
|
||||
package='',
|
||||
syntax='proto3',
|
||||
serialized_options=b'\n\026eu.pretix.secrets.sig1B\014TicketProtos',
|
||||
create_key=_descriptor._internal_create_key,
|
||||
serialized_pb=b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42&\n\x16\x65u.pretix.secrets.sig1B\x0cTicketProtosb\x06proto3'
|
||||
)
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42\x33\n#eu.pretix.libpretixsync.crypto.sig1B\x0cTicketProtosb\x06proto3')
|
||||
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pretix_sig1_pb2', globals())
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
|
||||
|
||||
|
||||
_TICKET = _descriptor.Descriptor(
|
||||
name='Ticket',
|
||||
full_name='Ticket',
|
||||
filename=None,
|
||||
file=DESCRIPTOR,
|
||||
containing_type=None,
|
||||
create_key=_descriptor._internal_create_key,
|
||||
fields=[
|
||||
_descriptor.FieldDescriptor(
|
||||
name='seed', full_name='Ticket.seed', index=0,
|
||||
number=1, type=9, cpp_type=9, label=1,
|
||||
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='item', full_name='Ticket.item', index=1,
|
||||
number=2, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='variation', full_name='Ticket.variation', index=2,
|
||||
number=3, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='subevent', full_name='Ticket.subevent', index=3,
|
||||
number=4, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
],
|
||||
extensions=[
|
||||
],
|
||||
nested_types=[],
|
||||
enum_types=[
|
||||
],
|
||||
serialized_options=None,
|
||||
is_extendable=False,
|
||||
syntax='proto3',
|
||||
extension_ranges=[],
|
||||
oneofs=[
|
||||
],
|
||||
serialized_start=21,
|
||||
serialized_end=94,
|
||||
)
|
||||
|
||||
DESCRIPTOR.message_types_by_name['Ticket'] = _TICKET
|
||||
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||
|
||||
Ticket = _reflection.GeneratedProtocolMessageType('Ticket', (_message.Message,), {
|
||||
'DESCRIPTOR' : _TICKET,
|
||||
'__module__' : 'pretix_sig1_pb2'
|
||||
# @@protoc_insertion_point(class_scope:Ticket)
|
||||
})
|
||||
_sym_db.RegisterMessage(Ticket)
|
||||
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n#eu.pretix.libpretixsync.crypto.sig1B\014TicketProtos'
|
||||
_TICKET._serialized_start=21
|
||||
_TICKET._serialized_end=94
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -210,7 +210,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
fee += min(p.price, Decimal(keep_fee_per_ticket))
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects,
|
||||
source=("pretix.cancelevent", None))
|
||||
refund_amount = o.payment_refund_sum
|
||||
|
||||
try:
|
||||
|
||||
@@ -101,12 +101,12 @@ error_messages = {
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period for this event has ended.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period for this event has ended.'),
|
||||
'payment_ended': _('All payments for this event need to be confirmed already, so no new orders can be created.'),
|
||||
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
|
||||
'some_subevent_not_started': _('The booking period for this event has not yet started. The affected positions '
|
||||
'have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
'voucher_invalid': _('This voucher code is not known in our database.'),
|
||||
@@ -147,6 +147,45 @@ error_messages = {
|
||||
}
|
||||
|
||||
|
||||
def _get_quota_availability(quota_diff, now_dt):
|
||||
quotas_ok = defaultdict(int)
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[k for k, v in quota_diff.items() if v > 0])
|
||||
qa.compute(now_dt=now_dt)
|
||||
for quota, count in quota_diff.items():
|
||||
if count <= 0:
|
||||
quotas_ok[quota] = 0
|
||||
break
|
||||
avail = qa.results[quota]
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
|
||||
def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ids):
|
||||
vouchers_ok = {}
|
||||
_voucher_depend_on_cart = set()
|
||||
for voucher, count in voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=event) &
|
||||
Q(expires__gte=now_dt)
|
||||
).exclude(pk__in=exclude_position_ids)
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if cart_count > 0:
|
||||
_voucher_depend_on_cart.add(voucher)
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
return vouchers_ok, _voucher_depend_on_cart
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||
@@ -819,43 +858,13 @@ class CartManager:
|
||||
self._quota_diff.update(quota_diff)
|
||||
self._operations += operations
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = defaultdict(int)
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[k for k, v in self._quota_diff.items() if v > 0])
|
||||
qa.compute(now_dt=self.now_dt)
|
||||
for quota, count in self._quota_diff.items():
|
||||
if count <= 0:
|
||||
quotas_ok[quota] = 0
|
||||
break
|
||||
avail = qa.results[quota]
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
def _get_voucher_availability(self):
|
||||
vouchers_ok = {}
|
||||
self._voucher_depend_on_cart = set()
|
||||
for voucher, count in self._voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability(
|
||||
self.event, self._voucher_use_diff, self.now_dt,
|
||||
exclude_position_ids=[
|
||||
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
cart_count = redeemed_in_carts.count()
|
||||
v_avail = voucher.max_usages - voucher.redeemed - cart_count
|
||||
if cart_count > 0:
|
||||
self._voucher_depend_on_cart.add(voucher)
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
]
|
||||
)
|
||||
return vouchers_ok
|
||||
|
||||
def _check_min_max_per_product(self):
|
||||
@@ -908,7 +917,7 @@ class CartManager:
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
|
||||
@@ -66,8 +67,8 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
|
||||
@@ -101,9 +102,9 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
timezone = e.settings.timezone
|
||||
region = e.settings.region
|
||||
else:
|
||||
locale = settings.LANGUAGE_CODE
|
||||
timezone = settings.TIME_ZONE
|
||||
region = None
|
||||
locale = organizer.settings.locale or settings.LANGUAGE_CODE
|
||||
timezone = organizer.settings.timezone or settings.TIME_ZONE
|
||||
region = organizer.settings.region
|
||||
with language(locale, region), override(timezone):
|
||||
if form_data.get('events') is not None:
|
||||
if isinstance(form_data['events'][0], str):
|
||||
@@ -119,12 +120,21 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
continue
|
||||
ex = response(events, organizer, set_progress)
|
||||
if ex.identifier == provider:
|
||||
if (
|
||||
isinstance(ex, OrganizerLevelExportMixin) and
|
||||
not staff_session and
|
||||
not (device or token or user).has_organizer_permission(organizer, ex.organizer_required_permission)
|
||||
):
|
||||
raise ExportError(
|
||||
gettext('You do not have sufficient permission to perform this export.')
|
||||
)
|
||||
|
||||
d = ex.render(form_data)
|
||||
if d is None:
|
||||
raise ExportError(
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
@@ -77,7 +77,7 @@ class LockTimeoutException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LockReleaseException(Exception):
|
||||
class LockReleaseException(LockTimeoutException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -180,5 +180,5 @@ def release_event_redis(event):
|
||||
lock.release()
|
||||
except RedisError:
|
||||
logger.exception('Error releasing an event lock')
|
||||
raise LockTimeoutException()
|
||||
raise LockReleaseException()
|
||||
event._lock = None
|
||||
|
||||
@@ -542,7 +542,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
if not any(c >= 500 for c in smtp_codes):
|
||||
# Not a permanent failure (mailbox full, service unavailable), retry later, but with large intervals
|
||||
try:
|
||||
self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800][self.request.retries])
|
||||
self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries])
|
||||
except MaxRetriesExceededError:
|
||||
# ignore and go on with logging the error
|
||||
pass
|
||||
|
||||
@@ -195,7 +195,8 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
||||
user=user,
|
||||
data={'source': 'import'}
|
||||
)
|
||||
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False)
|
||||
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False,
|
||||
source=('pretix.orderimport', None))
|
||||
Transaction.objects.bulk_create(save_transactions)
|
||||
|
||||
for o in orders:
|
||||
|
||||
@@ -113,8 +113,8 @@ error_messages = {
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'not_started': _('The booking period for this event has not yet started.'),
|
||||
'ended': _('The booking period has ended.'),
|
||||
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
|
||||
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
|
||||
'number of times allowed. We removed this item from your cart.'),
|
||||
@@ -125,9 +125,9 @@ error_messages = {
|
||||
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
|
||||
'removed this item from your cart.'),
|
||||
'voucher_required': _('You need a valid voucher code to order one of the products.'),
|
||||
'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The '
|
||||
'some_subevent_not_started': _('The booking period for one of the events in your cart has not yet started. The '
|
||||
'affected positions have been removed from your cart.'),
|
||||
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
|
||||
'some_subevent_ended': _('The booking period for one of the events in your cart has ended. The affected '
|
||||
'positions have been removed from your cart.'),
|
||||
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||
@@ -148,7 +148,7 @@ def mark_order_paid(*args, **kwargs):
|
||||
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
|
||||
|
||||
|
||||
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None):
|
||||
def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None, source=None):
|
||||
"""
|
||||
Reactivates a canceled order. If ``force`` is not set to ``True``, this will fail if there is not
|
||||
enough quota.
|
||||
@@ -189,7 +189,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
for m in position.granted_memberships.all():
|
||||
m.canceled = False
|
||||
m.save()
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
else:
|
||||
raise OrderError(is_available)
|
||||
|
||||
@@ -202,7 +202,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
generate_invoice(order)
|
||||
|
||||
|
||||
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
|
||||
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None, source=None):
|
||||
"""
|
||||
Extends the deadline of an order. If the order is already expired, the quota will be checked to
|
||||
see if this is actually still possible. If ``force`` is set to ``True``, the result of this check
|
||||
@@ -231,7 +231,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
num_invoices = order.invoices.filter(is_cancellation=False).count()
|
||||
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
|
||||
generate_invoice(order)
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
change(was_expired=False)
|
||||
@@ -245,16 +245,17 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||
def mark_order_refunded(order, user=None, auth=None, api_token=None, source=None):
|
||||
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
|
||||
device = auth.pk if isinstance(auth, Device) else None
|
||||
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
|
||||
return _cancel_order(
|
||||
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
|
||||
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha,
|
||||
source=source
|
||||
)
|
||||
|
||||
|
||||
def mark_order_expired(order, user=None, auth=None):
|
||||
def mark_order_expired(order, user=None, auth=None, source=None):
|
||||
"""
|
||||
Mark this order as expired. This sets the payment status and returns the order object.
|
||||
:param order: The order to change
|
||||
@@ -273,13 +274,13 @@ def mark_order_expired(order, user=None, auth=None):
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i and not i.refered.exists():
|
||||
generate_cancellation(i)
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
|
||||
order_expired.send(order.event, order=order)
|
||||
return order
|
||||
|
||||
|
||||
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False):
|
||||
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False, source=None):
|
||||
"""
|
||||
Mark this order as approved
|
||||
:param order: The order to change
|
||||
@@ -292,7 +293,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
order.require_approval = False
|
||||
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
|
||||
order.save(update_fields=['require_approval', 'expires'])
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
|
||||
order.log_action('pretix.event.order.approved', user=user, auth=auth)
|
||||
if order.total == Decimal('0.00'):
|
||||
@@ -341,7 +342,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
return order.pk
|
||||
|
||||
|
||||
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None, source=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -365,7 +366,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
|
||||
order_denied.send(order.event, order=order)
|
||||
|
||||
@@ -386,7 +387,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
|
||||
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None, source=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -486,7 +487,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
data={'cancellation_fee': cancellation_fee, 'comment': comment})
|
||||
order.cancellation_requests.all().delete()
|
||||
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
@@ -813,7 +814,7 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
|
||||
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None,
|
||||
customer=None):
|
||||
customer=None, source=None):
|
||||
p = None
|
||||
sales_channel = get_all_sales_channels()[sales_channel]
|
||||
|
||||
@@ -915,7 +916,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
)
|
||||
|
||||
orderpositions = OrderPosition.transform_cart_positions(positions, order)
|
||||
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
|
||||
order.create_transactions(positions=orderpositions, fees=fees, is_new=True, source=source, dt_now=now_dt)
|
||||
order.log_action('pretix.event.order.placed')
|
||||
if order.require_approval:
|
||||
order.log_action('pretix.event.order.placed.require_approval')
|
||||
@@ -967,7 +968,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
|
||||
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
|
||||
gift_cards: list=None, shown_total=None, customer=None):
|
||||
gift_cards: list=None, shown_total=None, customer=None, source=None):
|
||||
if payment_provider:
|
||||
pprov = event.get_payment_providers().get(payment_provider)
|
||||
if not pprov:
|
||||
@@ -1026,7 +1027,7 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
|
||||
order, payment = _create_order(event, email, positions, now_dt, pprov,
|
||||
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
|
||||
gift_cards=gift_cards, shown_total=shown_total, customer=customer)
|
||||
gift_cards=gift_cards, shown_total=shown_total, customer=customer, source=source)
|
||||
|
||||
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
|
||||
if free_order_flow:
|
||||
@@ -1088,7 +1089,7 @@ def expire_orders(sender, **kwargs):
|
||||
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
|
||||
event_id = o.event_id
|
||||
if expire:
|
||||
mark_order_expired(o)
|
||||
mark_order_expired(o, source=("pretix.periodic", None))
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@@ -1281,7 +1282,7 @@ class OrderChangeManager:
|
||||
CancelFeeOperation = namedtuple('CancelFeeOperation', ('fee', 'price_diff'))
|
||||
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True):
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, source=None):
|
||||
self.order = order
|
||||
self.user = user
|
||||
self.auth = auth
|
||||
@@ -1296,6 +1297,7 @@ class OrderChangeManager:
|
||||
self.notify = notify
|
||||
self._invoice_dirty = False
|
||||
self._invoices = []
|
||||
self.source = source
|
||||
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
@@ -1944,14 +1946,14 @@ class OrderChangeManager:
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
elif isinstance(op.position, OrderFee):
|
||||
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
|
||||
'fee': op.position.pk,
|
||||
'fee_type': op.position.fee_type,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rate else None,
|
||||
'old_taxrule': op.position.tax_rule.pk if op.position.tax_rule else None,
|
||||
'new_taxrule': op.tax_rule.pk
|
||||
})
|
||||
op.position._calculate_tax(op.tax_rule)
|
||||
@@ -2331,9 +2333,9 @@ class OrderChangeManager:
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
self.order.touch()
|
||||
self.order.create_transactions()
|
||||
self.order.create_transactions(source=self.source)
|
||||
if self.split_order:
|
||||
self.split_order.create_transactions()
|
||||
self.split_order.create_transactions(source=self.source)
|
||||
|
||||
if self.notify:
|
||||
notify_user_changed_order(
|
||||
@@ -2368,12 +2370,12 @@ class OrderChangeManager:
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
|
||||
sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None):
|
||||
sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None, source=None):
|
||||
with language(locale):
|
||||
try:
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
|
||||
sales_channel, gift_cards, shown_total, customer)
|
||||
sales_channel, gift_cards, shown_total, customer, source=source)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
@@ -2503,11 +2505,12 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False,
|
||||
email_comment=None, refund_comment=None, cancel_invoice=True):
|
||||
email_comment=None, refund_comment=None, cancel_invoice=True, source=None):
|
||||
try:
|
||||
try:
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment)
|
||||
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment,
|
||||
source=source)
|
||||
if try_auto_refund:
|
||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
||||
comment=refund_comment)
|
||||
@@ -2519,7 +2522,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
|
||||
|
||||
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None, create_log=True,
|
||||
recreate_invoices=True):
|
||||
recreate_invoices=True, source=None):
|
||||
if not get_connection().in_atomic_block:
|
||||
raise Exception('change_payment_provider should only be called in atomic transaction!')
|
||||
|
||||
@@ -2607,7 +2610,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
||||
generate_cancellation(i)
|
||||
generate_invoice(order)
|
||||
|
||||
order.create_transactions()
|
||||
order.create_transactions(source=source)
|
||||
return old_fee, new_fee, fee, new_payment
|
||||
|
||||
|
||||
|
||||
@@ -2756,6 +2756,11 @@ PERSON_NAME_TITLE_GROUPS = OrderedDict([
|
||||
'Dr.',
|
||||
'Prof.',
|
||||
'Prof. Dr.',
|
||||
))),
|
||||
('dr_prof_he', ('Dr., Prof., H.E.', (
|
||||
'Dr.',
|
||||
'Prof.',
|
||||
'H.E.',
|
||||
)))
|
||||
])
|
||||
|
||||
@@ -3042,7 +3047,7 @@ settings_hierarkey.add_type(LazyI18nStringList,
|
||||
settings_hierarkey.add_type(RelativeDateWrapper,
|
||||
serialize=lambda rdw: rdw.to_string(),
|
||||
unserialize=lambda s: RelativeDateWrapper.from_string(s))
|
||||
settings_hierarkey.add_type(PhoneNumber, lambda pn: pn.as_international, lambda s: parse(s))
|
||||
settings_hierarkey.add_type(PhoneNumber, lambda pn: pn.as_international, lambda s: parse(s) if s else None)
|
||||
|
||||
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
|
||||
@@ -44,16 +44,6 @@ class BaseQuestionsViewMixin:
|
||||
form_class = BaseQuestionsForm
|
||||
all_optional = False
|
||||
|
||||
@staticmethod
|
||||
def _keyfunc(pos):
|
||||
# Sort addons after the item they are an addon to
|
||||
if isinstance(pos, OrderPosition):
|
||||
i = pos.addon_to.positionid if pos.addon_to else pos.positionid
|
||||
else:
|
||||
i = pos.addon_to.pk if pos.addon_to else pos.pk
|
||||
addon_penalty = 1 if pos.addon_to else 0
|
||||
return i, addon_penalty, pos.pk
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -215,6 +215,13 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
expected_exceptions = (ValidationError,)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
if not self._task_self.request.called_directly:
|
||||
self._task_self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': percentage}
|
||||
)
|
||||
|
||||
def __init_subclass__(cls):
|
||||
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, url_kwargs=None, url_args=None,
|
||||
organizer=None, event=None, user=None, session_key=None):
|
||||
@@ -240,6 +247,9 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
self.SessionStore = engine.SessionStore
|
||||
view_instance.request.session = self.SessionStore(session_key)
|
||||
|
||||
task_self = self
|
||||
view_instance._task_self = task_self
|
||||
|
||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||
form_class = view_instance.get_form_class()
|
||||
if form_kwargs.get('instance'):
|
||||
@@ -331,6 +341,13 @@ class AsyncPostView(AsyncMixin, View):
|
||||
expected_exceptions = (ValidationError,)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
if not self._task_self.request.called_directly:
|
||||
self._task_self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={'value': percentage}
|
||||
)
|
||||
|
||||
def __init_subclass__(cls):
|
||||
def async_execute(self, *, request_path, url_args, url_kwargs, query_string, post_data, locale, tz,
|
||||
organizer=None, event=None, user=None, session_key=None):
|
||||
@@ -355,6 +372,9 @@ class AsyncPostView(AsyncMixin, View):
|
||||
self.SessionStore = engine.SessionStore
|
||||
view_instance.request.session = self.SessionStore(session_key)
|
||||
|
||||
task_self = self
|
||||
view_instance._task_self = task_self
|
||||
|
||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||
return view_instance.async_post(view_instance.request, *url_args, **url_kwargs)
|
||||
|
||||
|
||||
@@ -589,7 +589,7 @@ class EventSettingsForm(SettingsForm):
|
||||
(k, '{scheme}: {samples}'.format(
|
||||
scheme=v[0],
|
||||
samples=', '.join(v[1])
|
||||
))
|
||||
) if v[0] != ', '.join(v[1]) else v[0])
|
||||
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||
]
|
||||
if not self.event.has_subevents:
|
||||
|
||||
@@ -557,7 +557,7 @@ class OrderApprove(OrderView):
|
||||
def post(self, *args, **kwargs):
|
||||
if self.order.require_approval:
|
||||
try:
|
||||
approve_order(self.order, user=self.request.user)
|
||||
approve_order(self.order, user=self.request.user, source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
@@ -608,7 +608,8 @@ class OrderDeny(OrderView):
|
||||
try:
|
||||
deny_order(self.order, user=self.request.user,
|
||||
comment=self.request.POST.get('comment'),
|
||||
send_mail=self.request.POST.get('send_email') == 'on')
|
||||
send_mail=self.request.POST.get('send_email') == 'on',
|
||||
source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
@@ -703,7 +704,7 @@ class OrderRefundProcess(OrderView):
|
||||
|
||||
if self.order.status != Order.STATUS_CANCELED and self.order.positions.exists():
|
||||
if self.request.POST.get("action") == "r":
|
||||
mark_order_refunded(self.order, user=self.request.user)
|
||||
mark_order_refunded(self.order, user=self.request.user, source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
elif not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
@@ -1071,7 +1072,7 @@ class OrderRefundView(OrderView):
|
||||
if any_success:
|
||||
if self.start_form.cleaned_data.get('action') == 'mark_refunded':
|
||||
if self.order.cancel_allowed():
|
||||
mark_order_refunded(self.order, user=self.request.user)
|
||||
mark_order_refunded(self.order, user=self.request.user, source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
elif self.start_form.cleaned_data.get('action') == 'mark_pending':
|
||||
if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
@@ -1178,7 +1179,7 @@ class OrderTransition(OrderView):
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
def post(self, request, *args, **kwargs):
|
||||
to = self.request.POST.get('status', '')
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p' and self.mark_paid_form.is_valid():
|
||||
ps = self.mark_paid_form.cleaned_data['amount']
|
||||
@@ -1267,38 +1268,42 @@ class OrderTransition(OrderView):
|
||||
'confirmation mail.'))
|
||||
else:
|
||||
messages.success(self.request, _('The payment has been created successfully.'))
|
||||
elif self.order.cancel_allowed() and to == 'c' and self.mark_canceled_form.is_valid():
|
||||
try:
|
||||
cancel_order(self.order.pk, user=self.request.user,
|
||||
email_comment=self.mark_canceled_form.cleaned_data['comment'],
|
||||
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
|
||||
cancel_invoice=self.mark_canceled_form.cleaned_data.get('cancel_invoice', True),
|
||||
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'))
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
self.order.refresh_from_db()
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
with language(self.order.locale):
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}&giftcard={}&comment={}'.format(
|
||||
round_decimal(self.order.pending_sum * -1),
|
||||
'true' if self.req and self.req.refund_as_giftcard else 'false',
|
||||
quote(gettext('Order canceled'))
|
||||
))
|
||||
elif self.order.cancel_allowed() and to == 'c':
|
||||
if self.mark_canceled_form.is_valid():
|
||||
try:
|
||||
cancel_order(self.order.pk, user=self.request.user,
|
||||
email_comment=self.mark_canceled_form.cleaned_data['comment'],
|
||||
send_mail=self.mark_canceled_form.cleaned_data['send_email'],
|
||||
cancel_invoice=self.mark_canceled_form.cleaned_data.get('cancel_invoice', True),
|
||||
cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee'),
|
||||
source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
self.order.refresh_from_db()
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
with language(self.order.locale):
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}&giftcard={}&comment={}'.format(
|
||||
round_decimal(self.order.pending_sum * -1),
|
||||
'true' if self.req and self.req.refund_as_giftcard else 'false',
|
||||
quote(gettext('Order canceled'))
|
||||
))
|
||||
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
else:
|
||||
return self.get(self.request, *args, **kwargs)
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'e':
|
||||
mark_order_expired(self.order, user=self.request.user)
|
||||
mark_order_expired(self.order, user=self.request.user, source=("pretix.control", f"user:{self.request.user.pk}"))
|
||||
messages.success(self.request, _('The order has been marked as expired.'))
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
def get(self, request, *args, **kwargs):
|
||||
to = self.request.GET.get('status', '')
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p':
|
||||
return render(self.request, 'pretixcontrol/order/pay.html', {
|
||||
@@ -1571,7 +1576,8 @@ class OrderReactivate(OrderView):
|
||||
reactivate_order(
|
||||
self.order,
|
||||
user=self.request.user,
|
||||
force=self.reactivate_form.cleaned_data.get('force', False)
|
||||
force=self.reactivate_form.cleaned_data.get('force', False),
|
||||
source=("pretix.control", f"user:{self.request.user.pk}"),
|
||||
)
|
||||
messages.success(self.request, _('The order has been reactivated.'))
|
||||
except OrderError as e:
|
||||
|
||||
@@ -65,6 +65,7 @@ from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import manually_retry_all_calls
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
|
||||
@@ -1508,7 +1509,19 @@ class ExportMixin:
|
||||
)
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
id = self.request.GET.get("identifier") or self.request.POST.get("exporter")
|
||||
for ex in sorted([response(events, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
raw_exporters = [
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
|
||||
for r, response in responses
|
||||
if response
|
||||
]
|
||||
raw_exporters = [
|
||||
ex for ex in raw_exporters
|
||||
if (
|
||||
not isinstance(ex, OrganizerLevelExportMixin) or
|
||||
self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
|
||||
)
|
||||
]
|
||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||
if id and ex.identifier != id:
|
||||
continue
|
||||
|
||||
@@ -1526,18 +1539,19 @@ class ExportMixin:
|
||||
initial=initial
|
||||
)
|
||||
ex.form.fields = ex.export_form_fields
|
||||
ex.form.fields.update([
|
||||
('events',
|
||||
forms.ModelMultipleChoiceField(
|
||||
queryset=events,
|
||||
initial=events,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
label=_('Events'),
|
||||
required=True
|
||||
)),
|
||||
])
|
||||
if not isinstance(ex, OrganizerLevelExportMixin):
|
||||
ex.form.fields.update([
|
||||
('events',
|
||||
forms.ModelMultipleChoiceField(
|
||||
queryset=events,
|
||||
initial=events,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
label=_('Events'),
|
||||
required=True
|
||||
)),
|
||||
])
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
|
||||
@@ -763,7 +763,7 @@ def item_meta_values(request, organizer, event):
|
||||
or request.user.teams.filter(all_events=True, organizer=organizer, can_change_items=True).exists()
|
||||
)
|
||||
if not all_access:
|
||||
defaults = matches.filter(
|
||||
defaults = defaults.filter(
|
||||
event__id__in=request.user.teams.filter(can_change_items=True).values_list(
|
||||
'limit_events__id', flat=True
|
||||
)
|
||||
|
||||
@@ -74,7 +74,30 @@ def SafeCell(*args, value=None, **kwargs):
|
||||
return c
|
||||
|
||||
|
||||
class SafeAppendMixin:
|
||||
class SafeWriteOnlyWorksheet(WriteOnlyWorksheet):
|
||||
def append(self, row):
|
||||
if not isgenerator(row) and not isinstance(row, (list, tuple, range)):
|
||||
self._invalid_row(row)
|
||||
|
||||
self._get_writer()
|
||||
|
||||
if self._rows is None:
|
||||
self._rows = self._write_rows()
|
||||
next(self._rows)
|
||||
|
||||
filtered_row = []
|
||||
for content in row:
|
||||
if isinstance(content, Cell):
|
||||
filtered_row.append(content)
|
||||
else:
|
||||
filtered_row.append(
|
||||
SafeCell(self, row=1, column=1, value=remove_invalid_excel_chars(content))
|
||||
)
|
||||
|
||||
self._rows.send(filtered_row)
|
||||
|
||||
|
||||
class SafeWorksheet(Worksheet):
|
||||
def append(self, iterable):
|
||||
row_idx = self._current_row + 1
|
||||
|
||||
@@ -105,21 +128,16 @@ class SafeAppendMixin:
|
||||
self._current_row = row_idx
|
||||
|
||||
|
||||
class SafeWriteOnlyWorksheet(SafeAppendMixin, WriteOnlyWorksheet):
|
||||
pass
|
||||
|
||||
|
||||
class SafeWorksheet(SafeAppendMixin, Worksheet):
|
||||
pass
|
||||
|
||||
|
||||
class SafeWorkbook(Workbook):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self._sheets:
|
||||
# monkeypatch existing sheets
|
||||
for s in self._sheets:
|
||||
s.append = SafeAppendMixin.append
|
||||
if self.write_only:
|
||||
s.append = SafeWriteOnlyWorksheet.append
|
||||
else:
|
||||
s.append = SafeWorksheet.append
|
||||
|
||||
def create_sheet(self, title=None, index=None):
|
||||
if self.read_only:
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||
"Language-Team: Arabic <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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
|
||||
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-12-06 23:00+0000\n"
|
||||
"Last-Translator: Ondřej Sokol <osokol@treesoft.cz>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-04-01 13:36+0000\n"
|
||||
"Last-Translator: Anna-itk <abc@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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-07-26 08:58+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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-07-26 08:58+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
|
||||
|
||||
+630
-617
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2019-10-03 19:00+0000\n"
|
||||
"Last-Translator: Chris Spy <chrispiropoulou@hotmail.com>\n"
|
||||
"Language-Team: Greek <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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-11-25 21:00+0000\n"
|
||||
"Last-Translator: Ismael Menéndez Fernández <ismael.menendez@balidea.com>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-11-10 05:00+0000\n"
|
||||
"Last-Translator: Jaakko Rinta-Filppula <jaakko@r-f.fi>\n"
|
||||
"Language-Team: Finnish <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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-04-07 10:40+0000\n"
|
||||
"Last-Translator: Eva-Maria Obermann <obermann@rami.io>\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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-02-22 22:00+0000\n"
|
||||
"Last-Translator: Ismael Menéndez Fernández <ismael.menendez@balidea.com>\n"
|
||||
"Language-Team: Galician <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-09-24 13:54+0000\n"
|
||||
"Last-Translator: ofirtro <ofir.tro@gmail.com>\n"
|
||||
"Language-Team: Hebrew <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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2020-01-24 08:00+0000\n"
|
||||
"Last-Translator: Prokaj Miklós <mixolid0@gmail.com>\n"
|
||||
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-05-08 19:00+0000\n"
|
||||
"Last-Translator: Emanuele Signoretta <signorettae@gmail.com>\n"
|
||||
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-03-15 00:00+0000\n"
|
||||
"Last-Translator: Yuriko Matsunami <y.matsunami@enobyte.com>\n"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-04-06 03:00+0000\n"
|
||||
"Last-Translator: Liga V <lerning_by_dreaming@gmx.de>\n"
|
||||
"Language-Team: Latvian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-06-20 02:00+0000\n"
|
||||
"Last-Translator: fyksen <fredrik@fyksen.me>\n"
|
||||
"Language-Team: Norwegian Bokmål <https://translate.pretix.eu/projects/pretix/"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-10-29 02:00+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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-08-05 04:00+0000\n"
|
||||
"Last-Translator: Maarten van den Berg <maartenberg1@gmail.com>\n"
|
||||
"Language-Team: Dutch (informal) <https://translate.pretix.eu/projects/pretix/"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2019-09-24 19:00+0000\n"
|
||||
"Last-Translator: Serge Bazanski <q3k@hackerspace.pl>\n"
|
||||
"Language-Team: Polish <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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+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
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2019-03-19 09:00+0000\n"
|
||||
"Last-Translator: Vitor Reis <vitor.reis7@gmail.com>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2020-10-27 06:00+0000\n"
|
||||
"Last-Translator: David Vaz <davidmgvaz@gmail.com>\n"
|
||||
"Language-Team: Portuguese (Portugal) <https://translate.pretix.eu/projects/"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2022-04-29 04:00+0000\n"
|
||||
"Last-Translator: Edd28 <chitu_edy@yahoo.com>\n"
|
||||
"Language-Team: Romanian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: 2021-08-09 13:10+0000\n"
|
||||
"Last-Translator: Svyatoslav <slava@digitalarthouse.eu>\n"
|
||||
"Language-Team: Russian <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
|
||||
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: 2022-09-29 09:24+0000\n"
|
||||
"POT-Creation-Date: 2022-10-11 09:30+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user