mirror of
https://github.com/pretix/pretix.git
synced 2026-06-18 02:26:17 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb82d013e6 | |||
| 3bc8450d4f | |||
| fdcad926f9 | |||
| 433262f6fc | |||
| 50596b7543 | |||
| 988188b00a | |||
| fdc15a753c | |||
| 785cc49a2e | |||
| 863fd3065a | |||
| ac361a8f47 | |||
| 56d928d5ec | |||
| 6c3e745d5d | |||
| b29efb9694 | |||
| 5ee1213dbf | |||
| c29dc49819 | |||
| 8b74f791f4 | |||
| 4d75438a11 | |||
| 781002b27e | |||
| f7c0e8c8d0 | |||
| 70a3516725 | |||
| 3133e18b22 | |||
| 3257c59117 | |||
| 19d1a8de71 | |||
| 0bb5af191b | |||
| 8fe56b7278 | |||
| df432b1958 | |||
| 54434f07a9 | |||
| 0ecbee48ae | |||
| ff2fa43ba1 | |||
| 3a1cefbbe7 | |||
| 7aa433e9af | |||
| c5a5d13158 | |||
| 2e256e30be | |||
| 0fbc0c3ffb | |||
| 93950d3fac | |||
| e8269ed1bf | |||
| 3fa1fbf6e2 | |||
| 8114b47c8c | |||
| dcf5e67196 | |||
| bf4569b080 | |||
| 95979143d7 | |||
| 4c5e77c2ef | |||
| 95b4f08aeb | |||
| d6605e668b | |||
| 6ee348548f | |||
| fca8e48f6a | |||
| 5a295934f7 | |||
| 4385b41e8b | |||
| 92dacfb966 | |||
| d1acbad181 | |||
| d0676765a4 | |||
| 9dd3b12625 | |||
| 738301d2af | |||
| f7f29e8a55 | |||
| ad69ec293f | |||
| 3443296a28 | |||
| 7a69e00d39 | |||
| bddc91d595 | |||
| 0c0d8b2c55 | |||
| c018921a18 | |||
| f33aa3fdba | |||
| 7b55f85663 | |||
| fb9909ca83 | |||
| 35e8bab7a5 | |||
| bf34e73121 | |||
| 39e2715f3c | |||
| 97d2b015cf | |||
| ca30a07da3 | |||
| 81d31ce64c | |||
| 0ae66ab7f6 | |||
| cb4af51c01 | |||
| 6b44cae607 | |||
| 1a4d4029c9 | |||
| 3563653d55 | |||
| e4c9afa87a | |||
| 6938397a6a | |||
| 24e5b593ea | |||
| cd237d4c19 | |||
| 9b1d7cc522 | |||
| d07948613a | |||
| eadc1b4812 | |||
| 787d4ec06b | |||
| ca1d13421f | |||
| 495ae25b9e | |||
| d98accdd2d | |||
| 746ced9e93 | |||
| d72bbffc51 | |||
| 8503623472 | |||
| 4f097e279a | |||
| 603225d042 | |||
| e5528f7784 | |||
| 2e702b87de | |||
| 59730ff501 | |||
| 280c24528f | |||
| ff09ed422c | |||
| b3be64b9f3 | |||
| 018c3d70e3 | |||
| a2f2d25169 | |||
| 4747a4c480 | |||
| ed9a9246e3 | |||
| 6e63d34932 | |||
| db06ed132a | |||
| ddbe38ca53 | |||
| d3698b3e2f | |||
| ff828ecc92 | |||
| d0236572f0 | |||
| 8ea6f3bc7d | |||
| 5587aebcd8 | |||
| 0b708067de | |||
| e75dc74661 | |||
| f0c5e54e34 | |||
| eeb6e11934 | |||
| 1238165e7a | |||
| bcf65603e4 | |||
| c4aed04a18 | |||
| 8be09ee937 | |||
| a1ec45daf6 |
@@ -81,5 +81,6 @@ jobs:
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: src/coverage.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
|
||||
|
||||
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
|
||||
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
|
||||
offers at `pretix.eu`_.
|
||||
|
||||
We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
|
||||
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other
|
||||
modern distributions, especially on all systemd-based ones.
|
||||
|
||||
Requirements
|
||||
|
||||
@@ -192,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
||||
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||
specifiers in requests
|
||||
(see below).
|
||||
Date range *either* two dates separated ``2022-03-18/2022-03-23``, ``2022-03-18/``,
|
||||
by ``/`` *or* the name of a ``/2022-03-23``, ``week_this``, ``week_next``,
|
||||
defined range. ``month_this``
|
||||
===================== ============================ ===================================
|
||||
|
||||
Query parameters
|
||||
|
||||
@@ -98,6 +98,8 @@ Endpoints
|
||||
:query string ends_after: Exclude all check-in lists attached to a sub-event that is already in the past at the given time.
|
||||
:query string expand: Expand a field into a full object. Currently only ``subevent`` is supported. Can be passed multiple times.
|
||||
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``name``, and ``subevent__date_from``,
|
||||
Default: ``subevent__date_from,name``
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:statuscode 200: no error
|
||||
|
||||
@@ -35,6 +35,12 @@ tax_rule integer The internal ID
|
||||
admission boolean ``true`` for items that grant admission to the event
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
personalized boolean ``true`` for items that require personalization according
|
||||
to event settings. Only affects system-level fields, not
|
||||
custom questions. Currently only allowed for products with
|
||||
``admission`` set to ``true``. For backwards compatibility,
|
||||
when creating new items and this field is not given, it defaults
|
||||
to the same value as ``admission``.
|
||||
position integer An integer, used for sorting
|
||||
picture file A product picture to be displayed in the shop
|
||||
(can be ``null``).
|
||||
@@ -158,7 +164,7 @@ meta_data object Values set for
|
||||
|
||||
.. versionchanged:: 4.16
|
||||
|
||||
The ``variations[x].meta_data`` attribute has been added.
|
||||
The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
@@ -213,6 +219,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -329,6 +336,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -426,6 +434,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -510,6 +519,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
@@ -626,6 +636,7 @@ Endpoints
|
||||
"tax_rate": "0.00",
|
||||
"tax_rule": 1,
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
|
||||
@@ -76,6 +76,10 @@ The exporter class
|
||||
|
||||
This is an abstract attribute, you **must** override this!
|
||||
|
||||
.. autoattribute:: description
|
||||
|
||||
.. autoattribute:: category
|
||||
|
||||
.. autoattribute:: export_form_fields
|
||||
|
||||
.. automethod:: render
|
||||
|
||||
@@ -17,9 +17,13 @@ Field Type Description
|
||||
id integer Internal layout ID
|
||||
name string Internal layout description
|
||||
default boolean ``true`` if this is the default layout
|
||||
layout object Layout specification for libpretixprint
|
||||
layout list Dynamic layout specification. Each list element
|
||||
corresponds to one dynamic element of the layout.
|
||||
The current version of the schema in use can be found
|
||||
`here`_.
|
||||
Submitting invalid content can lead to application errors.
|
||||
background URL Background PDF file
|
||||
item_assignments list of objects Products this layout is assigned to
|
||||
item_assignments list of objects Products this layout is assigned to (currently read-only)
|
||||
├ sales_channel string Sales channel (defaults to ``web``).
|
||||
└ item integer Item ID
|
||||
===================================== ========================== =======================================================
|
||||
@@ -58,7 +62,7 @@ Endpoints
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
]
|
||||
@@ -96,7 +100,7 @@ Endpoints
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": {…},
|
||||
"background": {},
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
@@ -147,3 +151,122 @@ Endpoints
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
|
||||
|
||||
Creates a new ticket layout
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create a layout for
|
||||
:param event: The ``slug`` field of the event to create a layout for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The layout could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
|
||||
|
||||
Update a layout. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"name": "Default layout"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default layout",
|
||||
"default": true,
|
||||
"layout": […],
|
||||
"background": null,
|
||||
"item_assignments": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the layout to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The layout could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
|
||||
|
||||
Delete a layout.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the layout to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
|
||||
|
||||
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json
|
||||
|
||||
@@ -97,6 +97,7 @@ overpayment
|
||||
param
|
||||
passphrase
|
||||
percental
|
||||
personalization
|
||||
pluggable
|
||||
positionid
|
||||
pre
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
|
||||
|
||||
|
||||
class FormFieldWrapperField(serializers.Field):
|
||||
@@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer):
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
elif isinstance(v, DateFrameField):
|
||||
self.fields[k] = SerializerDateFrameField(
|
||||
required=v.required,
|
||||
allow_null=not v.required,
|
||||
validators=v.validators,
|
||||
)
|
||||
else:
|
||||
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
|
||||
|
||||
@@ -151,5 +159,40 @@ class JobRunSerializer(serializers.Serializer):
|
||||
for k, v in self.fields.items():
|
||||
if isinstance(v, serializers.ManyRelatedField) and k not in data:
|
||||
data[k] = []
|
||||
|
||||
for fk in self.fields.keys():
|
||||
# Backwards compatibility for exports that used to take e.g. (date_from, date_to) or (event_date_from, event_date_to)
|
||||
# and now only take date_range.
|
||||
if fk.endswith("_range") and isinstance(self.fields[fk], SerializerDateFrameField) and fk not in data:
|
||||
if fk.replace("_range", "_from") in data:
|
||||
d_from = data.pop(fk.replace("_range", "_from"))
|
||||
if d_from:
|
||||
d_from = serializers.DateField().to_internal_value(d_from)
|
||||
else:
|
||||
d_from = None
|
||||
if fk.replace("_range", "_to") in data:
|
||||
d_to = data.pop(fk.replace("_range", "_to"))
|
||||
if d_to:
|
||||
d_to = serializers.DateField().to_internal_value(d_to)
|
||||
else:
|
||||
d_to = None
|
||||
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
|
||||
|
||||
data = super().to_internal_value(data)
|
||||
return data
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
super().is_valid(raise_exception=raise_exception)
|
||||
|
||||
fields_keys = set(self.fields.keys())
|
||||
input_keys = set(self.initial_data.keys())
|
||||
|
||||
additional_fields = input_keys - fields_keys
|
||||
|
||||
if bool(additional_fields):
|
||||
self._errors['fields'] = ['Additional fields not allowed: {}.'.format(list(additional_fields))]
|
||||
|
||||
if self._errors and raise_exception:
|
||||
raise ValidationError(self.errors)
|
||||
|
||||
return not bool(self._errors)
|
||||
|
||||
@@ -95,8 +95,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
variation = ItemVariation.objects.create(**validated_data)
|
||||
|
||||
if require_membership_types:
|
||||
variation.require_membership_types.add(*require_membership_types)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
for key, value in meta_data.items():
|
||||
@@ -230,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
|
||||
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
|
||||
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'personalized',
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
@@ -258,6 +262,15 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
|
||||
if data.get('personalized') and not data.get('admission'):
|
||||
raise ValidationError(_('Only admission products can currently be personalized.'))
|
||||
|
||||
if data.get('admission') and 'personalized' not in data and not self.instance:
|
||||
# Backwards compatibility
|
||||
data['personalized'] = True
|
||||
elif 'admission' in data and not data['admission']:
|
||||
data['personalized'] = False
|
||||
|
||||
if data.get('issue_giftcard'):
|
||||
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
|
||||
raise ValidationError(
|
||||
|
||||
@@ -118,6 +118,10 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'name': ['Do not specify name if you specified name_parts.']}
|
||||
)
|
||||
|
||||
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
|
||||
raise ValidationError({'name_parts': ['Invalid data type']})
|
||||
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
@@ -841,6 +845,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
|
||||
if data.get('attendee_name_parts') and not isinstance(data.get('attendee_name_parts'), dict):
|
||||
raise ValidationError({'attendee_name_parts': ['Invalid data type']})
|
||||
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
|
||||
|
||||
@@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||
a.question_id: a for a in instance.answers.all()
|
||||
}
|
||||
for answ_data in answers_data:
|
||||
if not answ_data.get('answer'):
|
||||
continue
|
||||
options = answ_data.pop('options', [])
|
||||
if answ_data['question'].pk in qs_seen:
|
||||
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
|
||||
if answ_data['question'].pk in answercache:
|
||||
a = answercache[answ_data['question'].pk]
|
||||
if isinstance(answ_data['answer'], File):
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
|
||||
@@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
|
||||
setattr(a, attr, value)
|
||||
a.save()
|
||||
else:
|
||||
if isinstance(answ_data['answer'], File):
|
||||
if isinstance(answ_data.get('answer'), File):
|
||||
an = answ_data.pop('answer')
|
||||
a = instance.answers.create(**answ_data, answer='')
|
||||
a.file.save(os.path.basename(an.name), an, save=False)
|
||||
|
||||
@@ -79,6 +79,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
validated_data['external_identifier'] = instance.external_identifier
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
|
||||
raise ValidationError({'name_parts': ['Invalid data type']})
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class CustomerCreateSerializer(CustomerSerializer):
|
||||
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||
|
||||
@@ -93,8 +93,10 @@ with scopes_disabled():
|
||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CheckinListSerializer
|
||||
queryset = CheckinList.objects.none()
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
filterset_class = CheckinListFilter
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
ordering_fields = ('subevent__date_from', 'id', 'name',)
|
||||
|
||||
def _get_permission_name(self, request):
|
||||
if request.path.endswith('/failed_checkins/'):
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
import datetime
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.timezone import now
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
@@ -33,6 +34,9 @@ from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
from pretix.base.models import CachedFile
|
||||
from pretix.helpers.images import (
|
||||
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
|
||||
)
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'image/gif': {'.gif'},
|
||||
@@ -61,6 +65,13 @@ class UploadView(APIView):
|
||||
name=file_obj.name,
|
||||
type=content_type
|
||||
))
|
||||
|
||||
if content_type in IMAGE_TYPES:
|
||||
try:
|
||||
validate_uploaded_file_for_valid_image(file_obj)
|
||||
except DjangoValidationError as e:
|
||||
raise ValidationError(e.message)
|
||||
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
date=now(),
|
||||
|
||||
@@ -42,7 +42,6 @@ from localflavor.fr.forms import FRZipCodeField
|
||||
from localflavor.gb.forms import GBPostcodeField
|
||||
from localflavor.gr.forms import GRPostalCodeField
|
||||
from localflavor.hr.forms import HRPostalCodeField
|
||||
from localflavor.id_.forms import IDPostCodeField
|
||||
from localflavor.ie.forms import EircodeField
|
||||
from localflavor.il.forms import ILPostalCodeField
|
||||
from localflavor.in_.forms import INZipCodeField
|
||||
@@ -80,8 +79,8 @@ COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
|
||||
# We don't presume this for countries we don't have knowledge about, there are countries in the
|
||||
# world e.g. without zipcodes
|
||||
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
|
||||
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX',
|
||||
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
|
||||
'GB', 'GR', 'HR', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'NL',
|
||||
'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
|
||||
}
|
||||
|
||||
|
||||
@@ -167,7 +166,6 @@ _zip_code_fields = {
|
||||
'GB': GBPostcodeField,
|
||||
'GR': GRPostalCodeField,
|
||||
'HR': HRPostalCodeField,
|
||||
'ID': IDPostCodeField,
|
||||
'IE': EircodeField,
|
||||
'IL': ILPostalCodeField,
|
||||
'IN': INZipCodeField,
|
||||
|
||||
@@ -520,20 +520,20 @@ def base_placeholders(sender, **kwargs):
|
||||
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url_remove', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
'url_remove', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.waitinglist.remove'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.waitinglist.remove',
|
||||
) + '?voucher=68CYU2H6ZTP3WLK5',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'url', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: build_absolute_uri(
|
||||
'url', ['waiting_list_voucher', 'event'],
|
||||
lambda waiting_list_voucher, event: build_absolute_uri(
|
||||
event, 'presale:event.redeem'
|
||||
) + '?voucher=' + waiting_list_entry.voucher.code,
|
||||
) + '?voucher=' + waiting_list_voucher.code,
|
||||
lambda event: build_absolute_uri(
|
||||
event,
|
||||
'presale:event.redeem',
|
||||
@@ -588,7 +588,7 @@ def base_placeholders(sender, **kwargs):
|
||||
_('Sample Admission Ticket')
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
|
||||
@@ -36,7 +36,7 @@ import io
|
||||
import tempfile
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from defusedcsv import csv
|
||||
@@ -84,6 +84,27 @@ class BaseExporter:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""
|
||||
A description for this exporter.
|
||||
"""
|
||||
return ""
|
||||
|
||||
@property
|
||||
def category(self) -> Optional[str]:
|
||||
"""
|
||||
A category name for this exporter, or ``None``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def featured(self) -> bool:
|
||||
"""
|
||||
If ``True``, this exporter will be highlighted.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -39,7 +39,7 @@ from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import QuestionAnswer
|
||||
|
||||
@@ -49,7 +49,10 @@ from ..signals import register_data_exporters
|
||||
|
||||
class AnswerFilesExporter(BaseExporter):
|
||||
identifier = 'answerfiles'
|
||||
verbose_name = _('Answers to file upload questions')
|
||||
verbose_name = _('Question answer file uploads')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = _('Download a ZIP file including all files that have been uploaded by your customers while creating '
|
||||
'an order.')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
|
||||
@@ -36,7 +36,7 @@ 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 django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
@@ -48,6 +48,8 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
organizer_required_permission = 'can_manage_customers'
|
||||
category = pgettext_lazy('export_category', 'Customer accounts')
|
||||
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
|
||||
@@ -23,22 +23,24 @@ import json
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
from django import forms
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext, gettext_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, OrderPayment
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
|
||||
|
||||
|
||||
class DekodiNREIExporter(BaseExporter):
|
||||
identifier = 'dekodi_nrei'
|
||||
verbose_name = 'dekodi NREI (JSON)'
|
||||
category = pgettext_lazy('export_category', 'Invoices')
|
||||
description = gettext_lazy("Download invoices in a format that can be used by the dekodi NREI conversion software.")
|
||||
|
||||
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
|
||||
|
||||
@@ -113,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo14': p.info_data.get('reference') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider.startswith('stripe'):
|
||||
elif p.provider and p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
@@ -192,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
|
||||
def render(self, form_data):
|
||||
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
|
||||
|
||||
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(date__gte=date_value)
|
||||
|
||||
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(date__lte=date_value)
|
||||
if form_data.get('date_range'):
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
|
||||
if d_start:
|
||||
qs = qs.filter(date__gte=d_start)
|
||||
if d_end:
|
||||
qs = qs.filter(date__lte=d_end)
|
||||
|
||||
jo = {
|
||||
'Format': 'NREI',
|
||||
@@ -218,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
|
||||
def export_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=gettext_lazy('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=gettext_lazy('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
help_text=gettext_lazy('Only include invoices issued in this time frame. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=gettext_lazy('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ..exporter import ListExporter
|
||||
@@ -45,6 +45,8 @@ from ..signals import register_multievent_data_exporters
|
||||
class EventDataExporter(ListExporter):
|
||||
identifier = 'eventdata'
|
||||
verbose_name = _('Event data')
|
||||
category = pgettext_lazy('export_category', 'Event data')
|
||||
description = _('Download a spreadsheet with information on all events in this organizer account.')
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
|
||||
@@ -38,13 +38,15 @@ from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from zipfile import ZipFile
|
||||
|
||||
import dateutil.parser
|
||||
from django import forms
|
||||
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext, gettext_lazy as _, pgettext
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import (
|
||||
gettext, gettext_lazy as _, pgettext, pgettext_lazy,
|
||||
)
|
||||
|
||||
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
|
||||
|
||||
@@ -57,30 +59,24 @@ from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
|
||||
|
||||
|
||||
class InvoiceExporterMixin:
|
||||
category = pgettext_lazy('export_category', 'Invoices')
|
||||
|
||||
@property
|
||||
def invoice_exporter_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
|
||||
help_text=_('Only include invoices issued in this time frame. Note that the invoice date does '
|
||||
'not always correspond to the order or payment date.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
|
||||
'does not always correspond to the order or payment date.')
|
||||
)),
|
||||
('payment_provider',
|
||||
forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
@@ -112,16 +108,12 @@ class InvoiceExporterMixin:
|
||||
)
|
||||
)
|
||||
qs = qs.filter(has_payment_with_provider=1)
|
||||
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(date__gte=date_value)
|
||||
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(date__lte=date_value)
|
||||
if form_data.get('date_range'):
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
|
||||
if d_start:
|
||||
qs = qs.filter(date__gte=d_start)
|
||||
if d_end:
|
||||
qs = qs.filter(date__lte=d_end)
|
||||
|
||||
return qs
|
||||
|
||||
@@ -129,6 +121,7 @@ class InvoiceExporterMixin:
|
||||
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
identifier = 'invoices'
|
||||
verbose_name = _('All invoices')
|
||||
description = _('Download all invoices created by the system as a ZIP file of PDF files.')
|
||||
|
||||
def render(self, form_data: dict, output_file=None):
|
||||
qs = self.invoices_queryset(form_data).filter(shredded=False)
|
||||
@@ -180,6 +173,10 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = _('Invoice data')
|
||||
description = _('Download a spreadsheet with the data of all invoices created by the system. The spreadsheet '
|
||||
'includes two sheets, one with a line for every invoice, and one with a line for every position of '
|
||||
'every invoice.')
|
||||
featured = True
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from openpyxl.styles import Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
@@ -48,6 +48,8 @@ def _min(a1, a2):
|
||||
class ItemDataExporter(ListExporter):
|
||||
identifier = 'itemdata'
|
||||
verbose_name = _('Product data')
|
||||
category = pgettext_lazy('export_category', 'Product data')
|
||||
description = _('Download a spreadsheet with details about all products and variations.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
locales = self.event.settings.locales
|
||||
@@ -73,6 +75,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Free price input"),
|
||||
_("Sales tax"),
|
||||
_("Is an admission ticket"),
|
||||
_("Personalized ticket"),
|
||||
_("Generate tickets"),
|
||||
_("Waiting list"),
|
||||
_("Available from"),
|
||||
@@ -144,6 +147,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.personalized else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
|
||||
@@ -187,6 +191,7 @@ class ItemDataExporter(ListExporter):
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
_("Yes") if i.personalized else "",
|
||||
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
|
||||
_("Yes") if i.allow_waitinglist else "",
|
||||
date_format(i.available_from.astimezone(self.timezone),
|
||||
|
||||
@@ -38,6 +38,8 @@ from decimal import Decimal
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
|
||||
@@ -46,7 +48,10 @@ from ..signals import register_data_exporters
|
||||
|
||||
class JSONExporter(BaseExporter):
|
||||
identifier = 'json'
|
||||
verbose_name = 'Order data (JSON)'
|
||||
verbose_name = lazy(lambda *args: gettext('Order data') + ' (JSON)', str)()
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a structured JSON representation of all orders. This might be useful for the '
|
||||
'import in third-party systems.')
|
||||
|
||||
def render(self, form_data):
|
||||
jo = {
|
||||
@@ -78,6 +83,7 @@ class JSONExporter(BaseExporter):
|
||||
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
|
||||
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
|
||||
'admission': item.admission,
|
||||
'personalized': item.personalized,
|
||||
'active': item.active,
|
||||
'sales_channels': item.sales_channels,
|
||||
'description': str(item.description),
|
||||
|
||||
@@ -36,7 +36,7 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
@@ -50,6 +50,8 @@ from ..signals import (
|
||||
class MailExporter(BaseExporter):
|
||||
identifier = 'mailaddrs'
|
||||
verbose_name = _('Email addresses (text file)')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = _("Download a text file with all email addresses collected either from buyers or from ticket holders.")
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
|
||||
|
||||
@@ -33,10 +33,8 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
|
||||
import dateutil
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import (
|
||||
@@ -46,8 +44,10 @@ from django.db.models import (
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
|
||||
)
|
||||
|
||||
from pretix.base.models import (
|
||||
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
|
||||
@@ -63,14 +63,24 @@ from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import (
|
||||
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
|
||||
)
|
||||
from ..forms.widgets import SplitDateTimePickerWidget
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..timeframes import (
|
||||
DateFrameField,
|
||||
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
|
||||
)
|
||||
|
||||
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = gettext_lazy('Order data')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one '
|
||||
'with a line for every order, one with a line for every order position, and one with '
|
||||
'a line for every additional fee charged in an order.')
|
||||
featured = True
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
@@ -105,41 +115,25 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
('date_from',
|
||||
forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False,
|
||||
help_text=_('Only include orders created on or after this date.')
|
||||
help_text=_('Only include orders created within this date range.')
|
||||
)),
|
||||
('date_to',
|
||||
forms.DateField(
|
||||
label=_('End date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
('event_date_range',
|
||||
DateFrameField(
|
||||
label=_('Event date'),
|
||||
include_future_frames=True,
|
||||
required=False,
|
||||
help_text=_('Only include orders created on or before this date.')
|
||||
)),
|
||||
('event_date_from',
|
||||
forms.DateField(
|
||||
label=_('Start event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
('event_date_to',
|
||||
forms.DateField(
|
||||
label=_('End event date'),
|
||||
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
|
||||
required=False,
|
||||
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
|
||||
help_text=_('Only include orders including at least one ticket for a date in this range. '
|
||||
'Will also include other dates in case of mixed orders!')
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
if not self.is_multievent and not self.event.has_subevents:
|
||||
del d['event_date_from']
|
||||
del d['event_date_to']
|
||||
del d['event_date_range']
|
||||
return d
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
@@ -182,45 +176,27 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
annotations = {}
|
||||
filters = {}
|
||||
|
||||
if form_data.get('date_from'):
|
||||
date_value = form_data.get('date_from')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
if form_data.get('date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
|
||||
if dt_start:
|
||||
filters[f'{rel}datetime__gte'] = dt_start
|
||||
if dt_end:
|
||||
filters[f'{rel}datetime__lt'] = dt_end
|
||||
|
||||
filters[f'{rel}datetime__gte'] = datetime_value
|
||||
|
||||
if form_data.get('date_to'):
|
||||
date_value = form_data.get('date_to')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
filters[f'{rel}datetime__lte'] = datetime_value
|
||||
|
||||
if form_data.get('event_date_from'):
|
||||
date_value = form_data.get('event_date_from')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
|
||||
|
||||
annotations['event_date_max'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_max__gte'] = datetime_value
|
||||
|
||||
if form_data.get('event_date_to'):
|
||||
date_value = form_data.get('event_date_to')
|
||||
if not isinstance(date_value, date):
|
||||
date_value = dateutil.parser.parse(date_value).date()
|
||||
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
|
||||
|
||||
annotations['event_date_min'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_min__lte'] = datetime_value
|
||||
if form_data.get('event_date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['event_date_range'], self.timezone)
|
||||
if dt_start:
|
||||
annotations['event_date_max'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_max__gte'] = dt_start
|
||||
if dt_end:
|
||||
annotations['event_date_min'] = Case(
|
||||
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
|
||||
default=F(f'{rel}event__date_from'),
|
||||
)
|
||||
filters['event_date_min__lt'] = dt_end
|
||||
|
||||
if filters:
|
||||
return qs.annotate(**annotations).filter(**filters)
|
||||
@@ -776,7 +752,10 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = gettext_lazy('Order payments and refunds')
|
||||
verbose_name = gettext_lazy('Payments and refunds')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all payments or refunds of every order.')
|
||||
featured = True
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -855,6 +834,8 @@ class PaymentListExporter(ListExporter):
|
||||
class QuotaListExporter(ListExporter):
|
||||
identifier = 'quotalist'
|
||||
verbose_name = gettext_lazy('Quota availabilities')
|
||||
category = pgettext_lazy('export_category', 'Product data')
|
||||
description = gettext_lazy('Download a spreadsheet of all quotas including their current availability.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
has_subevents = self.event.has_subevents
|
||||
@@ -908,21 +889,17 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
|
||||
|
||||
@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,
|
||||
('date_range',
|
||||
DateFrameField(
|
||||
label=_('Date range'),
|
||||
include_future_frames=False,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
d = OrderedDict(d)
|
||||
@@ -933,22 +910,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
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)
|
||||
)
|
||||
if form_data.get('date_range'):
|
||||
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
|
||||
if dt_start:
|
||||
qs = qs.filter(datetime__gte=dt_start)
|
||||
if dt_end:
|
||||
qs = qs.filter(datetime__lt=dt_end)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
@@ -978,6 +945,8 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
identifier = 'giftcardredemptionlist'
|
||||
verbose_name = gettext_lazy('Gift card redemptions')
|
||||
category = pgettext_lazy('export_category', 'Order data')
|
||||
description = gettext_lazy('Download a spreadsheet of all payments or refunds that involve gift cards.')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
payments = OrderPayment.objects.filter(
|
||||
@@ -1023,14 +992,18 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('date', forms.DateTimeField(
|
||||
('date', forms.SplitDateTimeField(
|
||||
label=_('Show value at'),
|
||||
initial=now(),
|
||||
required=False,
|
||||
widget=SplitDateTimePickerWidget(),
|
||||
help_text=_('Defaults to the time of report.')
|
||||
)),
|
||||
('testmode', forms.ChoiceField(
|
||||
label=_('Test mode'),
|
||||
@@ -1058,12 +1031,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
d = form_data.get('date') or now()
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk'),
|
||||
datetime__lte=form_data['date']
|
||||
datetime__lte=d
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
qs = self.organizer.issued_gift_cards.filter(
|
||||
issuance__lte=form_data['date']
|
||||
issuance__lte=d
|
||||
).annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
|
||||
).order_by('issuance').prefetch_related(
|
||||
@@ -1078,11 +1052,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
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']))
|
||||
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=d))
|
||||
elif form_data.get('state') == 'expired_value':
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
|
||||
qs = qs.exclude(cached_value=0).filter(expires__lt=d)
|
||||
elif form_data.get('state') == 'expired':
|
||||
qs = qs.filter(expires__lt=form_data['date'])
|
||||
qs = qs.filter(expires__lt=d)
|
||||
|
||||
headers = [
|
||||
_('Gift card code'),
|
||||
|
||||
@@ -39,6 +39,8 @@ from ..signals import (
|
||||
class WaitingListExporter(ListExporter):
|
||||
identifier = 'waitinglist'
|
||||
verbose_name = _('Waiting list')
|
||||
category = pgettext_lazy('export_category', 'Waiting list')
|
||||
description = _('Download a spread sheet with all your waiting list data.')
|
||||
|
||||
# map selected status to label and queryset-filter
|
||||
status_filters = [
|
||||
|
||||
@@ -531,7 +531,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
|
||||
code='aspect_ratio_not_3_by_4',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception('foo')
|
||||
logger.exception('Could not parse image')
|
||||
# Pillow doesn't recognize it as an image.
|
||||
if isinstance(exc, ValidationError):
|
||||
raise
|
||||
@@ -575,7 +575,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
@@ -584,7 +584,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
@@ -595,7 +595,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
}
|
||||
)
|
||||
)
|
||||
if item.admission and event.settings.attendee_company_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
@@ -603,7 +603,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.admission and event.settings.attendee_addresses_asked:
|
||||
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
|
||||
@@ -30,7 +30,6 @@ from django.urls import get_script_prefix
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.translation import LANGUAGE_SESSION_KEY
|
||||
from django.utils.translation.trans_real import (
|
||||
check_for_language, get_supported_language_variant, language_code_re,
|
||||
parse_accept_lang_header,
|
||||
@@ -128,12 +127,7 @@ def get_language_from_user_settings(request: HttpRequest) -> str:
|
||||
return lang_code
|
||||
|
||||
|
||||
def get_language_from_session_or_cookie(request: HttpRequest) -> str:
|
||||
if hasattr(request, 'session'):
|
||||
lang_code = request.session.get(LANGUAGE_SESSION_KEY)
|
||||
if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
|
||||
return lang_code
|
||||
|
||||
def get_language_from_cookie(request: HttpRequest) -> str:
|
||||
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
|
||||
try:
|
||||
return get_supported_language_variant(lang_code)
|
||||
@@ -187,14 +181,14 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
or get_default_language()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
get_language_from_session_or_cookie(request)
|
||||
get_language_from_cookie(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_user_settings(request)
|
||||
or get_language_from_browser(request)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.16 on 2022-12-21 08:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def item_set_personalized(apps, schema_editor):
|
||||
# We cannot really know if a position was bundled or an add-on, but we can at least guess
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Item.objects.filter(admission=True).update(personalized=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0226_itemvariationmetavalue'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='personalized',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
item_set_personalized,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 3.2.16 on 2023-01-18 11:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0227_item_personalized'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScheduledOrganizerExport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('export_identifier', models.CharField(max_length=190)),
|
||||
('export_form_data', models.JSONField(default=dict)),
|
||||
('locale', models.CharField(max_length=250)),
|
||||
('mail_additional_recipients', models.TextField()),
|
||||
('mail_additional_recipients_cc', models.TextField()),
|
||||
('mail_additional_recipients_bcc', models.TextField()),
|
||||
('mail_subject', models.CharField(max_length=250)),
|
||||
('mail_template', models.TextField()),
|
||||
('schedule_rrule', models.TextField(null=True)),
|
||||
('schedule_rrule_time', models.TimeField()),
|
||||
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
|
||||
('error_counter', models.IntegerField(default=0)),
|
||||
('error_last_message', models.TextField(null=True)),
|
||||
('timezone', models.CharField(default='UTC', max_length=100)),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.organizer')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledEventExport',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('export_identifier', models.CharField(max_length=190)),
|
||||
('export_form_data', models.JSONField(default=dict)),
|
||||
('locale', models.CharField(max_length=250)),
|
||||
('mail_additional_recipients', models.TextField()),
|
||||
('mail_additional_recipients_cc', models.TextField()),
|
||||
('mail_additional_recipients_bcc', models.TextField()),
|
||||
('mail_subject', models.CharField(max_length=250)),
|
||||
('mail_template', models.TextField()),
|
||||
('schedule_rrule', models.TextField(null=True)),
|
||||
('schedule_rrule_time', models.TimeField()),
|
||||
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
|
||||
('error_counter', models.IntegerField(default=0)),
|
||||
('error_last_message', models.TextField(null=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.event')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
@@ -30,6 +30,7 @@ from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
)
|
||||
from .exports import ScheduledEventExport, ScheduledOrganizerExport
|
||||
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
|
||||
@@ -35,14 +35,17 @@ from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
|
||||
from django.db import connection, models
|
||||
from django.db.models import (
|
||||
Count, Exists, F, Max, OuterRef, Q, Subquery, Value, Window,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.helpers import PostgresWindowFrame
|
||||
|
||||
|
||||
class CheckinList(LoggedModel):
|
||||
@@ -140,7 +143,54 @@ class CheckinList(LoggedModel):
|
||||
|
||||
@property
|
||||
def inside_count(self):
|
||||
return self.positions_inside.count()
|
||||
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
|
||||
# Use the simple query that works on all databases
|
||||
return self.positions_inside.count()
|
||||
|
||||
# Use the PostgreSQL-specific query using Window functions, which is a lot faster.
|
||||
# On a real-world example with ~100k tickets, of which ~17k are checked in, we observed
|
||||
# a speed-up from 29s (old) to a few hundred milliseconds (new)!
|
||||
# Why is this so much faster? The regular query get's PostgreSQL all busy with filtering
|
||||
# the tickets both by their belonging the event and checkin status at the same time, while
|
||||
# this query just iterates over all successful checkins on the list, and -- by the power
|
||||
# of window functions -- asks "is this an entry that is followed by no exit?". Then we
|
||||
# dedupliate by position and count it up.
|
||||
cl = self
|
||||
base_q, base_params = (
|
||||
Checkin.all.filter(successful=True, position__in=cl.positions, list=cl)
|
||||
.annotate(
|
||||
cnt_exists_after=Window(
|
||||
expression=Count("position_id", filter=Q(type=Value("exit"))),
|
||||
partition_by=[F("position_id"), F("list_id")],
|
||||
order_by=F("datetime").asc(),
|
||||
frame=PostgresWindowFrame(
|
||||
"ROWS", start="1 following", end="unbounded following"
|
||||
),
|
||||
)
|
||||
)
|
||||
.values("position_id", "type", "datetime", "cnt_exists_after")
|
||||
.query.sql_with_params()
|
||||
)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT COUNT("position_id")
|
||||
FROM ({str(base_q)} ) s
|
||||
WHERE "type" = %s AND "cnt_exists_after" = 0
|
||||
GROUP BY "position_id"
|
||||
) a;
|
||||
""",
|
||||
[
|
||||
*base_params,
|
||||
"entry",
|
||||
],
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if rows:
|
||||
return rows[0][0]
|
||||
return 0
|
||||
|
||||
@property
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -632,6 +632,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return super().presale_has_ended
|
||||
|
||||
def delete_all_orders(self, really=False):
|
||||
from .checkin import Checkin
|
||||
from .orders import (
|
||||
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
|
||||
)
|
||||
@@ -645,6 +646,7 @@ class Event(EventMixin, LoggedModel):
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
Checkin.objects.filter(list__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from dateutil.rrule import rrulestr
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.validators import RRuleValidator, multimail_validate
|
||||
|
||||
|
||||
class AbstractScheduledExport(LoggedModel):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
|
||||
export_identifier = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_("Export"),
|
||||
)
|
||||
export_form_data = models.JSONField(
|
||||
default=dict,
|
||||
encoder=DjangoJSONEncoder,
|
||||
)
|
||||
|
||||
owner = models.ForeignKey(
|
||||
"pretixbase.User",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
locale = models.CharField(
|
||||
verbose_name=_('Language'),
|
||||
max_length=250
|
||||
)
|
||||
|
||||
mail_additional_recipients = models.TextField(
|
||||
verbose_name=_('Additional recipients'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_additional_recipients_cc = models.TextField(
|
||||
verbose_name=_('Additional recipients (Cc)'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_additional_recipients_bcc = models.TextField(
|
||||
verbose_name=_('Additional recipients (Bcc)'),
|
||||
null=False, blank=True, validators=[multimail_validate],
|
||||
help_text=_("You can specify multiple recipients separated by commas.")
|
||||
)
|
||||
mail_subject = models.CharField(
|
||||
verbose_name=_('Subject'),
|
||||
max_length=250
|
||||
)
|
||||
mail_template = models.TextField(
|
||||
verbose_name=_('Message'),
|
||||
)
|
||||
|
||||
schedule_rrule = models.TextField(
|
||||
null=True, blank=True, validators=[RRuleValidator()]
|
||||
)
|
||||
schedule_rrule_time = models.TimeField(
|
||||
verbose_name=_("Requested start time"),
|
||||
help_text=_("The actual start time might be delayed depending on system load."),
|
||||
)
|
||||
schedule_next_run = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
error_counter = models.IntegerField(default=0)
|
||||
error_last_message = models.TextField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return self.mail_subject
|
||||
|
||||
def compute_next_run(self):
|
||||
tz = self.tz
|
||||
r = rrulestr(self.schedule_rrule)
|
||||
|
||||
base_dt = now().astimezone(tz).replace(tzinfo=None)
|
||||
if now().astimezone(tz).time() < self.schedule_rrule_time:
|
||||
base_dt -= timedelta(days=1)
|
||||
|
||||
new_d = r.after(base_dt, inc=False)
|
||||
if not new_d:
|
||||
self.schedule_next_run = None
|
||||
return
|
||||
|
||||
try:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz)
|
||||
except pytz.exceptions.AmbiguousTimeError:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz, is_dst=False)
|
||||
except pytz.exceptions.NonExistentTimeError:
|
||||
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time) + timedelta(hours=1), tz)
|
||||
|
||||
|
||||
class ScheduledEventExport(AbstractScheduledExport):
|
||||
event = models.ForeignKey(
|
||||
"pretixbase.Event", on_delete=models.CASCADE, related_name="scheduled_exports"
|
||||
)
|
||||
|
||||
@property
|
||||
def tz(self):
|
||||
return self.event.timezone
|
||||
|
||||
|
||||
class ScheduledOrganizerExport(AbstractScheduledExport):
|
||||
organizer = models.ForeignKey(
|
||||
"pretixbase.Organizer", on_delete=models.CASCADE, related_name="scheduled_exports"
|
||||
)
|
||||
timezone = models.CharField(max_length=100,
|
||||
default=settings.TIME_ZONE,
|
||||
verbose_name=_('Timezone'))
|
||||
|
||||
@property
|
||||
def tz(self):
|
||||
return pytz.timezone(self.timezone)
|
||||
@@ -62,6 +62,7 @@ from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
|
||||
from ...helpers.images import ImageSizeValidator
|
||||
from .event import Event, SubEvent
|
||||
|
||||
|
||||
@@ -310,6 +311,8 @@ class Item(LoggedModel):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
|
||||
:type admission: bool
|
||||
:param personalized: ``True``, if attendee information should be collected for this ticket
|
||||
:type personalized: bool
|
||||
:param picture: A product picture to be shown next to the product description
|
||||
:type picture: File
|
||||
:param available_from: The date this product goes on sale
|
||||
@@ -396,8 +399,14 @@ class Item(LoggedModel):
|
||||
admission = models.BooleanField(
|
||||
verbose_name=_("Is an admission ticket"),
|
||||
help_text=_(
|
||||
'Whether or not buying this product allows a person to enter '
|
||||
'your event'
|
||||
'Whether or not buying this product allows a person to enter your event'
|
||||
),
|
||||
default=False
|
||||
)
|
||||
personalized = models.BooleanField(
|
||||
verbose_name=_("Is a personalized ticket"),
|
||||
help_text=_(
|
||||
'Whether or not buying this product allows to enter attendee information'
|
||||
),
|
||||
default=False
|
||||
)
|
||||
@@ -421,7 +430,8 @@ class Item(LoggedModel):
|
||||
picture = models.ImageField(
|
||||
verbose_name=_("Product picture"),
|
||||
null=True, blank=True, max_length=255,
|
||||
upload_to=itempicture_upload_to
|
||||
upload_to=itempicture_upload_to,
|
||||
validators=[ImageSizeValidator()]
|
||||
)
|
||||
available_from = models.DateTimeField(
|
||||
verbose_name=_("Available from"),
|
||||
@@ -578,6 +588,10 @@ class Item(LoggedModel):
|
||||
return self.event.settings.show_quota_left
|
||||
return self.show_quota_left
|
||||
|
||||
@property
|
||||
def ask_attendee_data(self):
|
||||
return self.admission and self.personalized
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
|
||||
@@ -808,7 +808,7 @@ class Order(LockModel, LoggedModel):
|
||||
return True
|
||||
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
|
||||
for cp in positions:
|
||||
if (cp.item.admission and ask_names) or cp.item.questions.all():
|
||||
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
|
||||
return True
|
||||
|
||||
return False # nothing there to modify
|
||||
@@ -2688,7 +2688,7 @@ class CartPosition(AbstractPosition):
|
||||
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)
|
||||
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else 0), self.pk)
|
||||
sort_key = subevent_key + category_key + item_key + variation_key + line_key
|
||||
|
||||
if self.addon_to_id:
|
||||
|
||||
@@ -23,6 +23,7 @@ import json
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
@@ -149,7 +150,15 @@ class TaxRule(LoggedModel):
|
||||
rate = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name=_("Tax rate")
|
||||
validators=[
|
||||
MaxValueValidator(
|
||||
limit_value=Decimal("100.00"),
|
||||
),
|
||||
MinValueValidator(
|
||||
limit_value=Decimal("0.00"),
|
||||
),
|
||||
],
|
||||
verbose_name=_("Tax rate"),
|
||||
)
|
||||
price_includes_tax = models.BooleanField(
|
||||
verbose_name=_("The configured product prices include the tax amount"),
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db import models, transaction
|
||||
@@ -27,14 +28,16 @@ from django.db.models import F, Q, Sum
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.models import User, Voucher
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ...helpers.format import format_map
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation
|
||||
@@ -213,15 +216,74 @@ class WaitingListEntry(LoggedModel):
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
self.send_mail(
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(
|
||||
event=self.event,
|
||||
waiting_list_entry=self,
|
||||
waiting_list_voucher=v,
|
||||
event_or_subevent=self.subevent or self.event,
|
||||
),
|
||||
user=user,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.waitinglist.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, auth=None, auto_email=True,
|
||||
attach_other_files: list=None, attach_cached_files: list=None):
|
||||
"""
|
||||
Sends an email to the entry's contact address.
|
||||
|
||||
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, and ``recipient``
|
||||
parameters.
|
||||
|
||||
* Create a ``LogEntry`` with the email contents.
|
||||
|
||||
:param subject: Subject of the email
|
||||
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
|
||||
:param context: Dictionary to use for rendering the template
|
||||
:param log_entry_type: Key to be used for the log entry
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
"""
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
context['meta_' + k] = v
|
||||
|
||||
with language(self.locale, self.event.settings.region):
|
||||
mail(
|
||||
self.email,
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(event=self.event, waiting_list_entry=self),
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
recipient = self.email
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
subject = format_map(subject, context)
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event,
|
||||
self.locale,
|
||||
headers=headers,
|
||||
sender=sender,
|
||||
auto_email=auto_email,
|
||||
attach_other_files=attach_other_files,
|
||||
attach_cached_files=attach_cached_files,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_itemvar(event, item, variation):
|
||||
|
||||
+25
-2
@@ -35,6 +35,7 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -46,12 +47,15 @@ from collections import OrderedDict
|
||||
from functools import partial
|
||||
from io import BytesIO
|
||||
|
||||
import jsonschema
|
||||
from arabic_reshaper import ArabicReshaper
|
||||
from bidi.algorithm import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max, Min
|
||||
from django.dispatch import receiver
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import conditional_escape
|
||||
@@ -740,9 +744,9 @@ class Renderer:
|
||||
|
||||
if o['content'] == 'other' or o['content'] == 'other_i18n':
|
||||
if o['content'] == 'other_i18n':
|
||||
text = str(LazyI18nString(o['text_i18n']))
|
||||
text = str(LazyI18nString(o.get('text_i18n', {})))
|
||||
else:
|
||||
text = o['text']
|
||||
text = o.get('text', '')
|
||||
|
||||
def replace(x):
|
||||
if x.group(1).startswith('itemmeta:'):
|
||||
@@ -975,3 +979,22 @@ class Renderer:
|
||||
output.write(outbuffer)
|
||||
outbuffer.seek(0)
|
||||
return outbuffer
|
||||
|
||||
|
||||
@deconstructible
|
||||
class PdfLayoutValidator:
|
||||
def __call__(self, value):
|
||||
if not isinstance(value, dict):
|
||||
try:
|
||||
val = json.loads(value)
|
||||
except ValueError:
|
||||
raise ValidationError(_('Your layout file is not a valid JSON file.'))
|
||||
else:
|
||||
val = value
|
||||
with open(finders.find('schema/pdf-layout.schema.json'), 'r') as f:
|
||||
schema = json.loads(f.read())
|
||||
try:
|
||||
jsonschema.validate(val, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
e = str(e).replace('%', '%%')
|
||||
raise ValidationError(_('Your layout file is not a valid layout. Error message: {}').format(e))
|
||||
|
||||
@@ -59,10 +59,10 @@ class RelativeDateWrapper:
|
||||
def date(self, event) -> datetime.date:
|
||||
from .models import SubEvent
|
||||
|
||||
if isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
elif isinstance(self.data, datetime.datetime):
|
||||
if isinstance(self.data, datetime.datetime):
|
||||
return self.data.date()
|
||||
elif isinstance(self.data, datetime.date):
|
||||
return self.data
|
||||
else:
|
||||
if self.data.minutes_before is not None:
|
||||
raise ValueError('A minute-based relative datetime can not be used as a date')
|
||||
|
||||
@@ -140,7 +140,7 @@ error_messages = {
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': _('You need to select a specific seat.'),
|
||||
'seat_invalid': _('Please select a valid seat.'),
|
||||
|
||||
@@ -19,31 +19,49 @@
|
||||
# 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 typing import Any, Dict
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import override
|
||||
from django.db import connection, transaction
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now, override
|
||||
from django.utils.translation import gettext
|
||||
from django_scopes import scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.email import get_email_context
|
||||
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,
|
||||
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
|
||||
User, cachedfile_name,
|
||||
)
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.tasks import (
|
||||
ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||
EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||
)
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
periodic_task, register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportError(LazyLocaleException):
|
||||
pass
|
||||
|
||||
|
||||
class ExportEmptyError(ExportError):
|
||||
pass
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True)
|
||||
def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||
def set_progress(val):
|
||||
@@ -56,7 +74,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
||||
file = CachedFile.objects.get(id=fileid)
|
||||
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
|
||||
responses = register_data_exporters.send(event)
|
||||
for receiver, response in responses:
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(event, event.organizer, set_progress)
|
||||
@@ -106,16 +124,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
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 form_data.get('events') is not None and not form_data.get('all_events'):
|
||||
if isinstance(form_data['events'][0], str):
|
||||
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||
events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events
|
||||
events = allowed_events.filter(organizer=organizer)
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
|
||||
for receiver, response in responses:
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(events, organizer, set_progress)
|
||||
@@ -138,3 +156,210 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
return file.pk
|
||||
|
||||
|
||||
def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission):
|
||||
with language(schedule.locale, context.settings.region), override(schedule.tz):
|
||||
file = CachedFile(web_download=False)
|
||||
file.date = now()
|
||||
file.expires = now() + timedelta(hours=24)
|
||||
file.save()
|
||||
|
||||
def _handle_error(msg, soft=False):
|
||||
context.log_action(
|
||||
'pretix.event.export.schedule.failed',
|
||||
data={
|
||||
'id': schedule.id,
|
||||
'export_identifier': schedule.export_identifier,
|
||||
'export_form_data': schedule.export_form_data,
|
||||
'reason': msg,
|
||||
'soft': soft,
|
||||
}
|
||||
)
|
||||
if schedule.owner.is_active:
|
||||
mail(
|
||||
email=schedule.owner.email,
|
||||
subject=gettext('Export failed'),
|
||||
template='pretixbase/email/export_failed.txt',
|
||||
context={
|
||||
'configuration_url': config_url,
|
||||
'reason': msg,
|
||||
'soft': soft,
|
||||
},
|
||||
event=context if isinstance(context, Event) else None,
|
||||
organizer=context.organizer if isinstance(context, Event) else context,
|
||||
locale=schedule.locale,
|
||||
)
|
||||
if not soft:
|
||||
schedule.error_counter += 1
|
||||
schedule.error_last_message = msg
|
||||
schedule.save(update_fields=['error_counter', 'error_last_message'])
|
||||
|
||||
if not has_permission:
|
||||
_handle_error(gettext('Permission denied.'))
|
||||
return
|
||||
|
||||
try:
|
||||
if not exporter:
|
||||
raise ExportError("Export type not found.")
|
||||
d = exporter.render(schedule.export_form_data)
|
||||
if d is None:
|
||||
raise ExportEmptyError(
|
||||
gettext('Your export did not contain any data.')
|
||||
)
|
||||
file.filename, file.type, data = d
|
||||
filesize = len(data)
|
||||
if filesize > 20 * 1024 * 1024: # 20 MB
|
||||
raise ExportError(
|
||||
gettext('Your exported data exceeded the size limit for scheduled exports.')
|
||||
)
|
||||
f = ContentFile(data)
|
||||
file.file.save(cachedfile_name(file, file.filename), f)
|
||||
except ExportEmptyError as e:
|
||||
_handle_error(str(e), soft=True)
|
||||
except ExportError as e:
|
||||
_handle_error(str(e), soft=False)
|
||||
except Exception:
|
||||
logger.exception("Scheduled export failed.")
|
||||
try:
|
||||
retry_func()
|
||||
except MaxRetriesExceededError:
|
||||
_handle_error('Internal Error')
|
||||
else:
|
||||
schedule.error_counter = 0
|
||||
schedule.save(update_fields=['error_counter'])
|
||||
to = [r for r in schedule.mail_additional_recipients.split(",") if r]
|
||||
cc = [r for r in schedule.mail_additional_recipients_cc.split(",") if r]
|
||||
bcc = [r for r in schedule.mail_additional_recipients_bcc.split(",") if r]
|
||||
if to:
|
||||
# If there is an explicit To, the owner is Cc. Otherwise, the owner is To. Yes, this is
|
||||
# purely cosmetical and has policital reasons.
|
||||
cc.append(schedule.owner.email)
|
||||
else:
|
||||
to.append(schedule.owner.email)
|
||||
|
||||
mail(
|
||||
email=to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=schedule.mail_subject,
|
||||
template=LazyI18nString(schedule.mail_template),
|
||||
context=get_email_context(event=context) if isinstance(context, Event) else {},
|
||||
event=context if isinstance(context, Event) else None,
|
||||
organizer=context.organizer if isinstance(context, Event) else context,
|
||||
locale=schedule.locale,
|
||||
attach_cached_files=[file],
|
||||
)
|
||||
context.log_action(
|
||||
'pretix.event.export.schedule.executed',
|
||||
data={
|
||||
'id': schedule.id,
|
||||
'export_identifier': schedule.export_identifier,
|
||||
'export_form_data': schedule.export_form_data,
|
||||
'result_file_size': filesize,
|
||||
'result_file_name': file.file.name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=OrganizerTask, bind=True, max_retries=5, default_retry_delay=120)
|
||||
def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None:
|
||||
schedule = organizer.scheduled_exports.get(pk=schedule)
|
||||
|
||||
allowed_events = schedule.owner.get_events_with_permission('can_view_orders')
|
||||
if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'):
|
||||
if isinstance(schedule.export_form_data['events'][0], str):
|
||||
events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer)
|
||||
else:
|
||||
events = allowed_events.filter(organizer=organizer)
|
||||
|
||||
responses = register_multievent_data_exporters.send(organizer)
|
||||
exporter = None
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(events, organizer)
|
||||
if ex.identifier == schedule.export_identifier:
|
||||
exporter = ex
|
||||
break
|
||||
|
||||
has_permission = schedule.owner.is_active
|
||||
if isinstance(exporter, OrganizerLevelExportMixin):
|
||||
if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission):
|
||||
has_permission = False
|
||||
|
||||
_run_scheduled_export(
|
||||
schedule,
|
||||
organizer,
|
||||
exporter,
|
||||
build_absolute_uri(
|
||||
'control:organizer.export',
|
||||
kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}
|
||||
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
|
||||
self.retry,
|
||||
has_permission,
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=EventTask, bind=True, max_retries=5, default_retry_delay=120)
|
||||
def scheduled_event_export(self, event: Event, schedule: int) -> None:
|
||||
schedule = event.scheduled_exports.get(pk=schedule)
|
||||
|
||||
responses = register_data_exporters.send(event)
|
||||
exporter = None
|
||||
for recv, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(event, event.organizer)
|
||||
if ex.identifier == schedule.export_identifier:
|
||||
exporter = ex
|
||||
break
|
||||
|
||||
has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders')
|
||||
|
||||
_run_scheduled_export(
|
||||
schedule,
|
||||
event,
|
||||
exporter,
|
||||
build_absolute_uri(
|
||||
'control:event.orders.export',
|
||||
kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
}
|
||||
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
|
||||
self.retry,
|
||||
has_permission,
|
||||
)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
@transaction.atomic
|
||||
def run_scheduled_exports(sender, **kwargs):
|
||||
qs = ScheduledEventExport.objects.filter(
|
||||
schedule_next_run__lt=now(),
|
||||
error_counter__lt=5,
|
||||
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('event')
|
||||
for s in qs:
|
||||
scheduled_event_export.apply_async(kwargs={
|
||||
'event': s.event_id,
|
||||
'schedule': s.pk,
|
||||
})
|
||||
s.compute_next_run()
|
||||
s.save(update_fields=['schedule_next_run'])
|
||||
qs = ScheduledOrganizerExport.objects.filter(
|
||||
schedule_next_run__lt=now(),
|
||||
error_counter__lt=5,
|
||||
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('organizer')
|
||||
for s in qs:
|
||||
scheduled_organizer_export.apply_async(kwargs={
|
||||
'organizer': s.organizer_id,
|
||||
'schedule': s.pk,
|
||||
})
|
||||
s.compute_next_run()
|
||||
s.save(update_fields=['schedule_next_run'])
|
||||
|
||||
@@ -100,7 +100,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
|
||||
plain_text_only=False, no_order_links=False):
|
||||
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -211,7 +211,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
|
||||
signature = ""
|
||||
|
||||
bcc = []
|
||||
bcc = list(bcc or [])
|
||||
|
||||
settings_holder = event or organizer
|
||||
|
||||
@@ -305,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
|
||||
send_task = mail_send_task.si(
|
||||
to=[email] if isinstance(email, str) else list(email),
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=subject,
|
||||
body=body_plain,
|
||||
@@ -357,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives):
|
||||
|
||||
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
||||
event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None,
|
||||
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
|
||||
attach_other_files: List[str] = None) -> bool:
|
||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||
html_with_cid, cid_images = replace_images_with_cid_paths(html)
|
||||
|
||||
@@ -1068,6 +1068,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
any_payment_failed = True
|
||||
except Exception:
|
||||
logger.exception('Error during payment attempt')
|
||||
else:
|
||||
order.refresh_from_db()
|
||||
|
||||
pending_sum = order.pending_sum
|
||||
free_order_flow = (
|
||||
@@ -1341,6 +1343,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||
|
||||
class OrderChangeManager:
|
||||
error_messages = {
|
||||
'order_canceled': _('You cannot change a canceled order.'),
|
||||
'product_without_variation': _('You need to select a variation of the product.'),
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'quota_missing': _('There is no quota defined that allows this operation.'),
|
||||
@@ -1388,6 +1391,9 @@ class OrderChangeManager:
|
||||
self._invoice_dirty = False
|
||||
self._invoices = []
|
||||
|
||||
if order.status == Order.STATUS_CANCELED:
|
||||
raise OrderError(self.error_messages['order_canceled'])
|
||||
|
||||
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):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
|
||||
@@ -109,6 +109,31 @@ class EventTask(app.Task):
|
||||
return ret
|
||||
|
||||
|
||||
class OrganizerTask(app.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
if 'organizer_id' in kwargs:
|
||||
organizer_id = kwargs.get('organizer_id')
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
del kwargs['organizer_id']
|
||||
kwargs['organizer'] = organizer
|
||||
elif 'organizer' in kwargs:
|
||||
organizer_id = kwargs.get('organizer')
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
kwargs['organizer'] = organizer
|
||||
else:
|
||||
args = list(args)
|
||||
organizer_id = args[0]
|
||||
with scopes_disabled():
|
||||
organizer = Organizer.objects.get(pk=organizer_id)
|
||||
args[0] = organizer
|
||||
|
||||
with scope(organizer=organizer):
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
return ret
|
||||
|
||||
|
||||
class OrganizerUserTask(app.Task):
|
||||
def __call__(self, *args, **kwargs):
|
||||
organizer_id = kwargs['organizer']
|
||||
|
||||
@@ -206,7 +206,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all tickets which include admission to the event."),
|
||||
help_text=_("Ask for a name for all personalized tickets."),
|
||||
)
|
||||
},
|
||||
'attendee_names_required': {
|
||||
@@ -229,10 +229,10 @@ DEFAULTS = {
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
|
||||
"only to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every admission ticket. This might be useful if you want to "
|
||||
"individual email addresses for every personalized ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders. However, "
|
||||
"pretix will send the order confirmation by default only to the one primary email address, not to "
|
||||
"the per-attendee addresses. You can however enable this in the E-mail settings."),
|
||||
"the per-attendee addresses. You can however enable this in the email settings."),
|
||||
)
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
@@ -242,7 +242,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
|
||||
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
@@ -2574,7 +2574,7 @@ Your {organizer} team"""))
|
||||
label=_("Attendee data explanation"),
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {'rows': '2'}},
|
||||
help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain "
|
||||
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
|
||||
"why you need information from them.")
|
||||
)
|
||||
},
|
||||
|
||||
@@ -218,8 +218,10 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
o.meta_info = json.dumps(d)
|
||||
o.save(update_fields=['meta_info', 'email', 'customer'])
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
|
||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
|
||||
for le in self.event.logentry_set.filter(
|
||||
Q(action_type__contains="order.email") | Q(action_type__contains="position.email"),
|
||||
):
|
||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject', 'full_mail'])
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
|
||||
shred_log_fields(le, banlist=['old_email', 'new_email'])
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% load i18n %}
|
||||
{% trans "Your export failed." %}
|
||||
|
||||
{% trans "Reason:" %} {{ reason }}
|
||||
|
||||
{% if not soft %}
|
||||
{% trans "If your export fails five times in a row, it will no longer be sent." %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Configuration link:" %}
|
||||
|
||||
{{ configuration_url }}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
{% with widget.subwidgets.0 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
<div class="row" data-display-dependency-value="custom" data-display-dependency="#{{ widget.subwidgets.0.attrs.id }}">
|
||||
<br>
|
||||
<div class="col-sm-6">
|
||||
{% with widget.subwidgets.1 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
{% with widget.subwidgets.2 as widget %}
|
||||
{% include widget.template_name %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
{% spaceless %}{% for widget in widget.subwidgets %}{% endfor %}{% endspaceless %}
|
||||
@@ -0,0 +1,433 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
import calendar
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from itertools import groupby
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy, pgettext_lazy
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.helpers.daterange import daterange
|
||||
|
||||
|
||||
def _quarter_start(ref_d):
|
||||
return ref_d.replace(day=1, month=1 + (ref_d.month - 1) // 3 * 3)
|
||||
|
||||
|
||||
def _week_start(ref_d):
|
||||
return ref_d - timedelta(ref_d.weekday())
|
||||
|
||||
|
||||
REPORTING_DATE_TIMEFRAMES = (
|
||||
# (identifier, label, start_inclusive, end_inclusive, includes_future, optgroup, describe)
|
||||
(
|
||||
'days_today',
|
||||
pgettext_lazy('reporting_timeframe', 'Today'),
|
||||
lambda ref_d: ref_d,
|
||||
lambda ref_d, start_d: start_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_yesterday',
|
||||
pgettext_lazy('reporting_timeframe', 'Yesterday'),
|
||||
lambda ref_d: ref_d - timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_last7',
|
||||
pgettext_lazy('reporting_timeframe', 'Last 7 days'),
|
||||
lambda ref_d: ref_d - timedelta(days=6),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_last14',
|
||||
pgettext_lazy('reporting_timeframe', 'Last 14 days'),
|
||||
lambda ref_d: ref_d - timedelta(days=13),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=13),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_tomorrow',
|
||||
pgettext_lazy('reporting_timeframe', 'Tomorrow'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d,
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_next7',
|
||||
pgettext_lazy('reporting_timeframe', 'Next 7 days'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'days_next14',
|
||||
pgettext_lazy('reporting_timeframe', 'Next 14 days'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=13),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by day'),
|
||||
daterange
|
||||
),
|
||||
(
|
||||
'week_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current week'),
|
||||
lambda ref_d: _week_start(ref_d),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current week to date'),
|
||||
lambda ref_d: _week_start(ref_d),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous week'),
|
||||
lambda ref_d: _week_start(ref_d) - timedelta(days=7),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'week_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next week'),
|
||||
lambda ref_d: _week_start(ref_d + timedelta(days=7)),
|
||||
lambda ref_d, start_d: start_d + timedelta(days=6),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by week'),
|
||||
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
|
||||
),
|
||||
(
|
||||
'month_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current month'),
|
||||
lambda ref_d: ref_d.replace(day=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current month to date'),
|
||||
lambda ref_d: ref_d.replace(day=1),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous month'),
|
||||
lambda ref_d: (ref_d.replace(day=1) - timedelta(days=1)).replace(day=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'month_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next month'),
|
||||
lambda ref_d: ref_d.replace(day=calendar.monthrange(ref_d.year, ref_d.month)[1]) + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by month'),
|
||||
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
|
||||
),
|
||||
(
|
||||
'quarter_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current quarter'),
|
||||
lambda ref_d: _quarter_start(ref_d),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current quarter to date'),
|
||||
lambda ref_d: _quarter_start(ref_d),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous quarter'),
|
||||
lambda ref_d: _quarter_start(_quarter_start(ref_d) - timedelta(days=1)),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'quarter_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next quarter'),
|
||||
lambda ref_d: ref_d.replace(
|
||||
day=calendar.monthrange(ref_d.year, _quarter_start(ref_d).month + 2)[1], month=_quarter_start(ref_d).month + 2
|
||||
) + timedelta(days=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by quarter'),
|
||||
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
|
||||
),
|
||||
(
|
||||
'year_this',
|
||||
pgettext_lazy('reporting_timeframe', 'Current year'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_to_date',
|
||||
pgettext_lazy('reporting_timeframe', 'Current year to date'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1),
|
||||
lambda ref_d, start_d: ref_d,
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_previous',
|
||||
pgettext_lazy('reporting_timeframe', 'Previous year'),
|
||||
lambda ref_d: (ref_d.replace(day=1, month=1) - timedelta(days=1)).replace(day=1, month=1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
False,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'year_next',
|
||||
pgettext_lazy('reporting_timeframe', 'Next year'),
|
||||
lambda ref_d: ref_d.replace(day=1, month=1, year=ref_d.year + 1),
|
||||
lambda ref_d, start_d: start_d.replace(day=31, month=12),
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'by year'),
|
||||
lambda start_d, end_d: str(start_d.year),
|
||||
),
|
||||
(
|
||||
'future',
|
||||
pgettext_lazy('reporting_timeframe', 'All future (excluding today)'),
|
||||
lambda ref_d: ref_d + timedelta(days=1),
|
||||
lambda ref_d, start_d: None,
|
||||
True,
|
||||
pgettext_lazy('reporting_timeframe', 'Other'),
|
||||
lambda start_d, end_d: date_format(start_d, "SHORT_DATE_FORMAT") + ' – ',
|
||||
),
|
||||
(
|
||||
'past',
|
||||
pgettext_lazy('reporting_timeframe', 'All past (including today)'),
|
||||
lambda ref_d: None,
|
||||
lambda ref_d, start_d: ref_d,
|
||||
True, # technically false, but only makes sense to have in a selection that also allows the future, otherwise redundant
|
||||
pgettext_lazy('reporting_timeframe', 'Other'),
|
||||
lambda start_d, end_d: ' – ' + date_format(end_d, "SHORT_DATE_FORMAT"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateFrameWidget(forms.MultiWidget):
|
||||
template_name = 'pretixbase/forms/widgets/dateframe.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.timeframe_choices = kwargs.pop('timeframe_choices')
|
||||
widgets = (
|
||||
forms.Select(choices=self.timeframe_choices),
|
||||
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'Start')}),
|
||||
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'End')}),
|
||||
)
|
||||
super().__init__(widgets=widgets, *args, **kwargs)
|
||||
|
||||
def decompress(self, value):
|
||||
if not value:
|
||||
return ['unset', None, None]
|
||||
if '/' in value:
|
||||
return [
|
||||
'custom',
|
||||
date.fromisoformat(value.split('/', 1)[0]),
|
||||
date.fromisoformat(value.split('/', 1)[-1]),
|
||||
]
|
||||
return [value, None, None]
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
ctx['required'] = self.timeframe_choices[0][0] == 'unset'
|
||||
return ctx
|
||||
|
||||
|
||||
def _describe_timeframe(label, start, end, future, describe):
|
||||
d_start = start(now())
|
||||
d_end = end(now(), d_start)
|
||||
details = describe(d_start, d_end)
|
||||
return f'{label} ({details})'
|
||||
|
||||
|
||||
class DateFrameField(forms.MultiValueField):
|
||||
default_error_messages = {
|
||||
**forms.MultiValueField.default_error_messages,
|
||||
'inconsistent': gettext_lazy('The end date must be after the start date.'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
include_future_frames = kwargs.pop('include_future_frames')
|
||||
|
||||
top_choices = [('custom', gettext_lazy('Custom timeframe'))]
|
||||
if not kwargs.get('required', True):
|
||||
top_choices.insert(0, ('unset', pgettext_lazy('reporting_timeframe', 'All time')))
|
||||
|
||||
_choices = []
|
||||
for grouper, group in groupby(REPORTING_DATE_TIMEFRAMES, key=lambda i: i[5]):
|
||||
options = [
|
||||
(identifier, _describe_timeframe(label, start, end, future, describe))
|
||||
for identifier, label, start, end, future, group, describe in group
|
||||
if include_future_frames or not future
|
||||
]
|
||||
if options:
|
||||
_choices.append((grouper, options))
|
||||
|
||||
timeframe_choices = [
|
||||
('', top_choices)
|
||||
] + _choices
|
||||
|
||||
fields = (
|
||||
forms.ChoiceField(
|
||||
choices=timeframe_choices,
|
||||
required=True
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
forms.DateField(
|
||||
required=False
|
||||
),
|
||||
)
|
||||
if 'widget' not in kwargs:
|
||||
kwargs['widget'] = DateFrameWidget(timeframe_choices=timeframe_choices)
|
||||
kwargs.pop('max_length', 0)
|
||||
kwargs.pop('empty_value', 0)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
def compress(self, data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
if data_list[0] == 'unset':
|
||||
return None
|
||||
elif data_list[0] == 'custom':
|
||||
return f'{data_list[1].isoformat() if data_list[1] else ""}/{data_list[2].isoformat() if data_list[2] else ""}'
|
||||
else:
|
||||
return data_list[0]
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
if initial is None:
|
||||
initial = self.widget.decompress(initial)
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return None
|
||||
if value[0] == 'custom':
|
||||
if not value[1] and not value[2]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
if value[1] and value[2] and self.fields[2].to_python(value[2]) < self.fields[1].to_python(value[1]):
|
||||
raise ValidationError(self.error_messages['inconsistent'])
|
||||
return super().clean(value)
|
||||
|
||||
|
||||
class SerializerDateFrameField(serializers.CharField):
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
resolve_timeframe_to_dates_inclusive(now(), data, pytz.UTC)
|
||||
except:
|
||||
raise ValidationError("Invalid date frame")
|
||||
|
||||
def to_representation(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
|
||||
"""
|
||||
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of dates
|
||||
where the first element ist the first possible date value within the timeframe and the second
|
||||
element is the last possible date value in the timeframe.
|
||||
Both returned values may be ``None`` for an unlimited interval.
|
||||
"""
|
||||
if isinstance(ref_dt, datetime):
|
||||
ref_dt = ref_dt.astimezone(timezone).date()
|
||||
if "/" in frame:
|
||||
start, end = frame.split("/", 1)
|
||||
return date.fromisoformat(start) if start else None, date.fromisoformat(end) if end else None
|
||||
for idf, label, start, end, includes_future, *args in REPORTING_DATE_TIMEFRAMES:
|
||||
if frame == idf:
|
||||
d_start = start(ref_dt)
|
||||
d_end = end(ref_dt, d_start)
|
||||
return d_start, d_end
|
||||
raise ValueError(f"Invalid timeframe '{frame}'")
|
||||
|
||||
|
||||
def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
|
||||
"""
|
||||
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
|
||||
where the first element ist the first possible datetime within the timeframe and the second
|
||||
element is the first possible datetime value *not* in the timeframe.
|
||||
Both returned values may be ``None`` for an unlimited interval.
|
||||
"""
|
||||
d_start, d_end = resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone)
|
||||
dt_start = make_aware(datetime.combine(d_start, time(0, 0, 0)), timezone) if d_start else None
|
||||
dt_end = make_aware(datetime.combine(d_end + timedelta(days=1), time(0, 0, 0)), timezone) if d_end else None
|
||||
return dt_start, dt_end
|
||||
@@ -19,6 +19,12 @@
|
||||
# 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 dateutil.rrule import rrulestr
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# 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>.
|
||||
@@ -32,11 +38,6 @@
|
||||
# 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 django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class BanlistValidator:
|
||||
|
||||
@@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator):
|
||||
banlist = [
|
||||
settings.PRETIX_EMAIL_NONE_VALUE,
|
||||
]
|
||||
|
||||
|
||||
def multimail_validate(val):
|
||||
s = val.split(',')
|
||||
for part in s:
|
||||
validate_email(part.strip())
|
||||
return s
|
||||
|
||||
|
||||
class RRuleValidator:
|
||||
def __call__(self, value):
|
||||
try:
|
||||
rrulestr(value)
|
||||
except Exception:
|
||||
raise ValidationError("Not a valid rrule.")
|
||||
|
||||
@@ -78,7 +78,7 @@ class BaseQuestionsViewMixin:
|
||||
form.pos = cartpos or orderpos
|
||||
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
|
||||
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
|
||||
(form.pos.addon_to.item.admission and form.pos.item.admission and (
|
||||
(form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and (
|
||||
self.request.event.settings.attendee_names_asked or
|
||||
self.request.event.settings.attendee_emails_asked or
|
||||
self.request.event.settings.attendee_company_asked or
|
||||
|
||||
@@ -28,7 +28,7 @@ from celery import states
|
||||
from celery.result import AsyncResult
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse, QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
from django.test import RequestFactory
|
||||
@@ -149,6 +149,8 @@ class AsyncMixin:
|
||||
return redirect(self.get_success_url(value))
|
||||
|
||||
def error(self, exception):
|
||||
if isinstance(exception, PermissionDenied):
|
||||
raise exception
|
||||
messages.error(self.request, self.get_error_message(exception))
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
@@ -337,8 +339,8 @@ class AsyncPostView(AsyncMixin, View):
|
||||
depend on the request object unless specifically supported by this class. File upload is currently also
|
||||
not supported.
|
||||
"""
|
||||
known_errortypes = ['ValidationError']
|
||||
expected_exceptions = (ValidationError,)
|
||||
known_errortypes = ['ValidationError', 'PermissionDenied']
|
||||
expected_exceptions = (ValidationError, PermissionDenied)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
def async_set_progress(self, percentage):
|
||||
|
||||
@@ -51,6 +51,9 @@ from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
from ...helpers.images import (
|
||||
IMAGE_EXTS, validate_uploaded_file_for_valid_image,
|
||||
)
|
||||
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
@@ -214,12 +217,16 @@ class ExtValidationMixin:
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
data = super().clean(*args, **kwargs)
|
||||
if isinstance(data, File):
|
||||
if isinstance(data, UploadedFile):
|
||||
filename = data.name
|
||||
ext = os.path.splitext(filename)[1]
|
||||
ext = ext.lower()
|
||||
if ext not in self.ext_whitelist:
|
||||
raise forms.ValidationError(_("Filetype not allowed!"))
|
||||
|
||||
if ext in IMAGE_EXTS:
|
||||
validate_uploaded_file_for_valid_image(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
# 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 decimal import Decimal
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, validate_email
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import (
|
||||
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
|
||||
@@ -65,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.base.validators import multimail_validate
|
||||
from pretix.control.forms import (
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
@@ -135,6 +137,8 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
|
||||
"here in percent. If you have a more complicated tax situation, you can add more tax rates and "
|
||||
"detailed configuration later."),
|
||||
max_value=Decimal("100.00"),
|
||||
min_value=Decimal("0.00"),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -861,13 +865,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
return data
|
||||
|
||||
|
||||
def multimail_validate(val):
|
||||
s = val.split(',')
|
||||
for part in s:
|
||||
validate_email(part.strip())
|
||||
return s
|
||||
|
||||
|
||||
def contains_web_channel_validate(val):
|
||||
if "web" not in val:
|
||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||
@@ -1206,8 +1203,8 @@ class MailSettingsForm(SettingsForm):
|
||||
'mail_text_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link': ['event', 'order'],
|
||||
'mail_subject_resend_link_attendee': ['event', 'order'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
|
||||
'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
|
||||
'mail_text_resend_all_links': ['event', 'orders'],
|
||||
'mail_subject_resend_all_links': ['event', 'orders'],
|
||||
'mail_attach_ical_description': ['event', 'event_or_subevent'],
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import ScheduledEventExport
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
|
||||
|
||||
class ScheduledEventExportForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledEventExport
|
||||
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
|
||||
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale']
|
||||
widgets = {
|
||||
'mail_additional_recipients': forms.TextInput,
|
||||
'mail_additional_recipients_cc': forms.TextInput,
|
||||
'mail_additional_recipients_bcc': forms.TextInput,
|
||||
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'] = forms.ChoiceField(
|
||||
label=_('Language'),
|
||||
choices=[(a, locale_names[a]) for a in self.instance.event.settings.locales]
|
||||
)
|
||||
|
||||
def clean_mail_additional_recipients(self):
|
||||
d = self.cleaned_data['mail_additional_recipients'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
def clean_mail_additional_recipients_cc(self):
|
||||
d = self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
def clean_mail_additional_recipients_bcc(self):
|
||||
d = self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
raise ValidationError(_('Please enter less than 25 recipients.'))
|
||||
return d
|
||||
|
||||
|
||||
class ScheduledOrganizerExportForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ScheduledOrganizerExport
|
||||
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
|
||||
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale', 'timezone']
|
||||
widgets = {
|
||||
'mail_additional_recipients': forms.TextInput,
|
||||
'mail_additional_recipients_cc': forms.TextInput,
|
||||
'mail_additional_recipients_bcc': forms.TextInput,
|
||||
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'] = forms.ChoiceField(
|
||||
label=_('Language'),
|
||||
choices=[(a, locale_names[a]) for a in self.instance.organizer.settings.locales]
|
||||
)
|
||||
self.fields['timezone'] = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Timezone"),
|
||||
)
|
||||
|
||||
def clean_mail_additional_recipients(self):
|
||||
return self.cleaned_data['mail_additional_recipients'].replace(' ', '')
|
||||
|
||||
def clean_mail_additional_recipients_cc(self):
|
||||
return self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
|
||||
|
||||
def clean_mail_additional_recipients_bcc(self):
|
||||
return self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
|
||||
@@ -2264,7 +2264,7 @@ class DeviceFilterForm(FilterForm):
|
||||
state = forms.ChoiceField(
|
||||
label=_('Device status'),
|
||||
choices=[
|
||||
('', _('All devices')),
|
||||
('all', _('All devices')),
|
||||
('active', _('Active devices')),
|
||||
('revoked', _('Revoked devices'))
|
||||
],
|
||||
|
||||
@@ -295,6 +295,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.user = kwargs.pop('user')
|
||||
kwargs.setdefault('initial', {})
|
||||
kwargs['initial'].setdefault('admission', True)
|
||||
kwargs['initial'].setdefault('personalized', True)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
@@ -403,6 +404,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.instance.sales_channels = list(get_all_sales_channels().keys())
|
||||
|
||||
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||
if not self.instance.admission:
|
||||
self.instance.personalized = False
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
|
||||
@@ -494,6 +497,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
'internal_name',
|
||||
'category',
|
||||
'admission',
|
||||
'personalized',
|
||||
'default_price',
|
||||
'tax_rule',
|
||||
]
|
||||
@@ -588,13 +592,33 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'tax_rule',
|
||||
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
|
||||
)
|
||||
if d['admission']:
|
||||
if d.get('admission'):
|
||||
self.add_error(
|
||||
'admission',
|
||||
_(
|
||||
"Gift card products should not be admission products at the same time."
|
||||
)
|
||||
)
|
||||
|
||||
if d.get('require_membership') and not d.get('require_membership_types'):
|
||||
self.add_error(
|
||||
'require_membership_types',
|
||||
_(
|
||||
"If a valid membership is required, at least one valid membership type needs to be selected."
|
||||
)
|
||||
)
|
||||
|
||||
if not d.get('admission'):
|
||||
d['personalized'] = False
|
||||
|
||||
if d.get('grant_membership_type'):
|
||||
if not d['grant_membership_type'].transferable and not d['personalized']:
|
||||
self.add_error(
|
||||
'personalized' if d['admission'] else 'admission',
|
||||
_("Your product grants a non-transferable membership and should therefore be a personalized "
|
||||
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
|
||||
"want the membership to be non-personalized, set the membership type to be transferable.")
|
||||
)
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
@@ -615,6 +639,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'active',
|
||||
'sales_channels',
|
||||
'admission',
|
||||
'personalized',
|
||||
'description',
|
||||
'picture',
|
||||
'default_price',
|
||||
@@ -792,6 +817,17 @@ class ItemVariationForm(I18nModelForm):
|
||||
}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d.get('require_membership') and not d.get('require_membership_types'):
|
||||
self.add_error(
|
||||
'require_membership_types',
|
||||
_(
|
||||
"If a valid membership is required, at least one valid membership type needs to be selected."
|
||||
)
|
||||
)
|
||||
return d
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit)
|
||||
self.meta_fields = []
|
||||
|
||||
@@ -227,6 +227,10 @@ class ExporterForm(forms.Form):
|
||||
elif isinstance(v, models.QuerySet):
|
||||
data[k] = [m.pk for m in v]
|
||||
|
||||
if 'all_events' in self.fields and 'events' in self.fields:
|
||||
if not data.get('all_events') and not data.get('events'):
|
||||
raise ValidationError(_('Please select some events.'))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -266,7 +270,7 @@ class OtherOperationsForm(forms.Form):
|
||||
notify = forms.BooleanField(
|
||||
label=_('Notify user'),
|
||||
required=False,
|
||||
initial=True,
|
||||
initial=False,
|
||||
help_text=_(
|
||||
'Send an email to the customer notifying that their order has been changed.'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
|
||||
from django import forms
|
||||
from django.utils.dates import MONTHS, WEEKDAYS
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
|
||||
|
||||
class RRuleForm(forms.Form):
|
||||
# TODO: calendar.setfirstweekday
|
||||
freq = forms.ChoiceField(
|
||||
choices=[
|
||||
('yearly', _('year(s)')),
|
||||
('monthly', _('month(s)')),
|
||||
('weekly', _('week(s)')),
|
||||
('daily', _('day(s)')),
|
||||
],
|
||||
initial='weekly'
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
label=_('Interval'),
|
||||
initial=1,
|
||||
min_value=1,
|
||||
widget=forms.NumberInput(attrs={'min': '1'})
|
||||
)
|
||||
dtstart = forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
initial=lambda: now().astimezone(get_current_timezone()).date()
|
||||
)
|
||||
|
||||
end = forms.ChoiceField(
|
||||
choices=[
|
||||
('count', ''),
|
||||
('until', ''),
|
||||
('forever', ''),
|
||||
],
|
||||
initial='count',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
count = forms.IntegerField(
|
||||
label=_('Number of repetitions'),
|
||||
initial=10
|
||||
)
|
||||
until = forms.DateField(
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
label=_('Last date'),
|
||||
required=True,
|
||||
initial=lambda: now() + timedelta(days=30)
|
||||
)
|
||||
|
||||
yearly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
yearly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_bymonth = forms.ChoiceField(
|
||||
choices=[
|
||||
(str(i), MONTHS[i]) for i in range(1, 13)
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
monthly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
monthly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
monthly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
weekly_byweekday = forms.MultipleChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
def parse_weekdays(self, value):
|
||||
m = {
|
||||
'MO': 0,
|
||||
'TU': 1,
|
||||
'WE': 2,
|
||||
'TH': 3,
|
||||
'FR': 4,
|
||||
'SA': 5,
|
||||
'SU': 6
|
||||
}
|
||||
if ',' in value:
|
||||
return [m.get(a) for a in value.split(',')]
|
||||
else:
|
||||
return m.get(value)
|
||||
|
||||
def to_rrule(self):
|
||||
rule_kwargs = {}
|
||||
rule_kwargs['dtstart'] = self.cleaned_data['dtstart']
|
||||
rule_kwargs['interval'] = self.cleaned_data['interval']
|
||||
|
||||
if self.cleaned_data['freq'] == 'yearly':
|
||||
freq = YEARLY
|
||||
if self.cleaned_data['yearly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(self.cleaned_data['yearly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['yearly_byweekday'])
|
||||
rule_kwargs['bymonth'] = int(self.cleaned_data['yearly_bymonth'])
|
||||
|
||||
elif self.cleaned_data['freq'] == 'monthly':
|
||||
freq = MONTHLY
|
||||
|
||||
if self.cleaned_data['monthly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(self.cleaned_data['monthly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['monthly_byweekday'])
|
||||
elif self.cleaned_data['freq'] == 'weekly':
|
||||
freq = WEEKLY
|
||||
|
||||
if self.cleaned_data['weekly_byweekday']:
|
||||
rule_kwargs['byweekday'] = [self.parse_weekdays(a) for a in self.cleaned_data['weekly_byweekday']]
|
||||
|
||||
elif self.cleaned_data['freq'] == 'daily':
|
||||
freq = DAILY
|
||||
|
||||
if self.cleaned_data['end'] == 'count':
|
||||
rule_kwargs['count'] = self.cleaned_data['count']
|
||||
elif self.cleaned_data['end'] == 'until':
|
||||
rule_kwargs['until'] = self.cleaned_data['until']
|
||||
return rrule(freq, **rule_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def initial_from_rrule(rule: rrule):
|
||||
initial = {}
|
||||
if isinstance(rule, str):
|
||||
rule = rrulestr(rule)
|
||||
|
||||
_rule = rule._original_rule
|
||||
initial['dtstart'] = rule._dtstart
|
||||
initial['interval'] = rule._interval
|
||||
|
||||
if rule._freq == YEARLY:
|
||||
initial['freq'] = 'yearly'
|
||||
initial['yearly_bysetpos'] = _rule.get('bysetpos')
|
||||
initial['yearly_byweekday'] = _rule.get('byweekday')
|
||||
initial['yearly_bymonth'] = _rule.get('bymonth')
|
||||
elif rule._freq == MONTHLY:
|
||||
initial['freq'] = 'monthly'
|
||||
initial['monthly_bysetpos'] = _rule.get('bysetpos')
|
||||
initial['monthly_byweekday'] = _rule.get('byweekday')
|
||||
elif rule._freq == WEEKLY:
|
||||
initial['freq'] = 'weekly'
|
||||
initial['weekly_byweekday'] = _rule.get('byweekday')
|
||||
elif rule._freq == DAILY:
|
||||
initial['freq'] = 'daily'
|
||||
|
||||
if rule._count:
|
||||
initial['end'] = 'count'
|
||||
initial['count'] = rule._count
|
||||
elif rule._until:
|
||||
initial['end'] = 'until'
|
||||
initial['until'] = rule._until
|
||||
else:
|
||||
initial['end'] = 'forever'
|
||||
return initial
|
||||
@@ -19,17 +19,15 @@
|
||||
# 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 datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import forms
|
||||
from django.forms import formset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.dates import MONTHS, WEEKDAYS
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.forms import I18nInlineFormSet
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
@@ -39,6 +37,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
|
||||
|
||||
@@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet):
|
||||
return form
|
||||
|
||||
|
||||
class RRuleForm(forms.Form):
|
||||
# TODO: calendar.setfirstweekday
|
||||
class RRuleFormSetForm(RRuleForm):
|
||||
exclude = forms.BooleanField(
|
||||
label=_('Exclude these dates instead of adding them.'),
|
||||
required=False
|
||||
)
|
||||
freq = forms.ChoiceField(
|
||||
choices=[
|
||||
('yearly', _('year(s)')),
|
||||
('monthly', _('month(s)')),
|
||||
('weekly', _('week(s)')),
|
||||
('daily', _('day(s)')),
|
||||
],
|
||||
initial='weekly'
|
||||
)
|
||||
interval = forms.IntegerField(
|
||||
label=_('Interval'),
|
||||
initial=1,
|
||||
min_value=1,
|
||||
widget=forms.NumberInput(attrs={'min': '1'})
|
||||
)
|
||||
dtstart = forms.DateField(
|
||||
label=_('Start date'),
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
initial=lambda: now().date()
|
||||
)
|
||||
|
||||
end = forms.ChoiceField(
|
||||
choices=[
|
||||
('count', ''),
|
||||
('until', ''),
|
||||
],
|
||||
initial='count',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
count = forms.IntegerField(
|
||||
label=_('Number of repetitions'),
|
||||
initial=10
|
||||
)
|
||||
until = forms.DateField(
|
||||
widget=forms.DateInput(
|
||||
attrs={
|
||||
'class': 'datepickerfield',
|
||||
'required': 'required'
|
||||
}
|
||||
),
|
||||
label=_('Last date'),
|
||||
required=True,
|
||||
initial=lambda: now() + timedelta(days=30)
|
||||
)
|
||||
|
||||
yearly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
yearly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
yearly_bymonth = forms.ChoiceField(
|
||||
choices=[
|
||||
(str(i), MONTHS[i]) for i in range(1, 13)
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
monthly_same = forms.ChoiceField(
|
||||
choices=[
|
||||
('on', ''),
|
||||
('off', ''),
|
||||
],
|
||||
initial='on',
|
||||
widget=forms.RadioSelect
|
||||
)
|
||||
monthly_bysetpos = forms.ChoiceField(
|
||||
choices=[
|
||||
('1', pgettext_lazy('rrule', 'first')),
|
||||
('2', pgettext_lazy('rrule', 'second')),
|
||||
('3', pgettext_lazy('rrule', 'third')),
|
||||
('-1', pgettext_lazy('rrule', 'last')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
monthly_byweekday = forms.ChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
|
||||
('MO,TU,WE,TH,FR', _('Weekday')),
|
||||
('SA,SU', _('Weekend day')),
|
||||
],
|
||||
required=False
|
||||
)
|
||||
|
||||
weekly_byweekday = forms.MultipleChoiceField(
|
||||
choices=[
|
||||
('MO', WEEKDAYS[0]),
|
||||
('TU', WEEKDAYS[1]),
|
||||
('WE', WEEKDAYS[2]),
|
||||
('TH', WEEKDAYS[3]),
|
||||
('FR', WEEKDAYS[4]),
|
||||
('SA', WEEKDAYS[5]),
|
||||
('SU', WEEKDAYS[6]),
|
||||
],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple
|
||||
)
|
||||
|
||||
def parse_weekdays(self, value):
|
||||
m = {
|
||||
'MO': 0,
|
||||
'TU': 1,
|
||||
'WE': 2,
|
||||
'TH': 3,
|
||||
'FR': 4,
|
||||
'SA': 5,
|
||||
'SU': 6
|
||||
}
|
||||
if ',' in value:
|
||||
return [m.get(a) for a in value.split(',')]
|
||||
else:
|
||||
return m.get(value)
|
||||
|
||||
|
||||
RRuleFormSet = formset_factory(
|
||||
RRuleForm,
|
||||
RRuleFormSetForm,
|
||||
can_order=False, can_delete=True, extra=1
|
||||
)
|
||||
|
||||
|
||||
@@ -315,6 +315,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.organizer.changed': _('The organizer has been changed.'),
|
||||
'pretix.organizer.settings': _('The organizer settings have been changed.'),
|
||||
'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'),
|
||||
'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'),
|
||||
'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'),
|
||||
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
||||
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||
'pretix.webhook.created': _('The webhook has been created.'),
|
||||
@@ -409,6 +414,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
||||
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
|
||||
'pretix.event.export.schedule.added': _('A scheduled export has been added.'),
|
||||
'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'),
|
||||
'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
||||
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/rrule.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
|
||||
|
||||
@@ -82,8 +82,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
@@ -125,7 +127,7 @@
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -198,7 +200,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-sign-in" aria-hidden="true"></span>
|
||||
{% trans "Check-In selected attendees" %}
|
||||
@@ -207,6 +209,8 @@
|
||||
<span class="fa fa-sign-out" aria-hidden="true"></span>
|
||||
{% trans "Check-Out selected attendees" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
{% trans "Delete all check-ins of selected attendees" %}
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
</div>
|
||||
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
||||
{% if request.event %}
|
||||
{% for l in request.event.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% for l, n in settings.LANGUAGES %}
|
||||
{% if l in request.event.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for l in request.organizer.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% for l, n in settings.LANGUAGES %}
|
||||
{% if l in request.organizer.settings.locales %}
|
||||
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Attendee data (once per admission ticket)" %}</h4>
|
||||
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
|
||||
|
||||
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
|
||||
|
||||
@@ -20,13 +20,19 @@
|
||||
<div class="col-md-9">
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
|
||||
<span class="fa fa-user"></span>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
<strong>{% trans "Admission product" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Every purchase of this product represents one person who is allowed to enter your event.
|
||||
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
|
||||
By default, we will only offer ticket downloads for these products.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Only purchases of such products will be considered "attendees" for most statistical
|
||||
purposes or within some plugins.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -40,12 +46,12 @@
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}>
|
||||
<span class="fa fa-cube"></span>
|
||||
<span class="fa fa-fw fa-cube"></span>
|
||||
<strong>{% trans "Non-admission product" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
|
||||
ticket downloads.
|
||||
A product that does not represent a person. By default, we will not offer ticket downloads
|
||||
(but you can still enable ticket downloads in event settings or product settings).
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -58,6 +64,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-circle-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.category layout="control" %}
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -27,13 +27,19 @@
|
||||
{% endfor %}
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
|
||||
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
<strong>{% trans "Admission product" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Every purchase of this product represents one person who is allowed to enter your event.
|
||||
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
|
||||
By default, we will only offer ticket downloads for these products.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Only purchases of such products will be considered "attendees" for most statistical
|
||||
purposes or within some plugins.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -51,8 +57,8 @@
|
||||
<strong>{% trans "Non-admission product" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
|
||||
ticket downloads.
|
||||
A product that does not represent a person. By default, we will not offer ticket downloads
|
||||
(but you can still enable ticket downloads in event settings or product settings).
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="help-block">
|
||||
@@ -65,6 +71,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" data-display-dependency="#admission_on">
|
||||
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
|
||||
<div class="col-md-9">
|
||||
{% for e in form.errors.personalized %}
|
||||
<div class="alert alert-danger has-error">
|
||||
{{ e }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-id-card-o"></span>
|
||||
<strong>{% trans "Personalized ticket" %}</strong><br>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
When this ticket is purchased, the system will ask for a name or other details according
|
||||
to your event settings.
|
||||
{% endblocktrans %}
|
||||
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
|
||||
<br>
|
||||
<span class="text-warning">
|
||||
<span class="fa fa-warning" aria-hidden="true"></span>
|
||||
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
|
||||
</span>
|
||||
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
|
||||
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="big-radio radio">
|
||||
<label>
|
||||
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
|
||||
<span class="fa fa-fw fa-file-text-o"></span>
|
||||
<strong>{% trans "Non-personalized ticket" %}</strong>
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
The system will not ask for a name or other attendee details. This only affects
|
||||
system-provided fields, you can still add your own questions.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% bootstrap_field form.description layout="control" %}
|
||||
{% bootstrap_field form.picture layout="control" %}
|
||||
{% bootstrap_field form.require_approval layout="control" %}
|
||||
|
||||
@@ -81,7 +81,11 @@
|
||||
</td>
|
||||
<td>
|
||||
{% if i.admission %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
|
||||
{% if i.personalized %}
|
||||
<span class="fa fa-id-card-o fa-fw text-muted" data-toggle="tooltip" title="{% trans "Personalized admission ticket" %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket without personalization" %}"></span>
|
||||
{% endif %}
|
||||
{% elif i.issue_giftcard %}
|
||||
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
|
||||
{% endif %}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<small>{% trans "All admission products" %}</small>
|
||||
<small>{% trans "All personalized products" %}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="dnd-container">
|
||||
|
||||
@@ -184,9 +184,11 @@
|
||||
<dt>{% trans "Order locale" %}</dt>
|
||||
<dd>
|
||||
{{ display_locale }}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if order.status == "n" %}
|
||||
<dt>{% trans "Expiry date" %}</dt>
|
||||
@@ -206,9 +208,11 @@
|
||||
{{ order.customer.identifier }} – {{ order.customer.email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt>{% trans "Contact email" %}</dt>
|
||||
@@ -217,21 +221,23 @@
|
||||
{% if order.email and order.email_known_to_work %}
|
||||
<span class="fa fa-check-circle text-success" data-toggle="tooltip" title="{% trans "We know that this email address works because the user clicked a link we sent them." %}"></span>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if order.email %}
|
||||
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if order.status != "c" %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
{% trans "Resend link" %}
|
||||
</button>
|
||||
</form>
|
||||
{% if order.email %}
|
||||
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
</a>
|
||||
{% if order.status != "c" %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
{% trans "Resend link" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
@@ -239,9 +245,11 @@
|
||||
<dt>{% trans "Phone number" %}</dt>
|
||||
<dd>
|
||||
{{ order.phone|default_if_none:""|phone_format }}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if invoices %}
|
||||
@@ -306,7 +314,7 @@
|
||||
{% trans "Email invoices" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_generate_invoice %}
|
||||
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
|
||||
<br/>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
@@ -317,7 +325,7 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% elif can_generate_invoice %}
|
||||
{% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %}
|
||||
<dt>{% trans "Invoices" %}</dt>
|
||||
<dd>
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
@@ -335,11 +343,11 @@
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right flip">
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change answers" %}
|
||||
</a>
|
||||
{% if 'can_change_orders' in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change answers" %}
|
||||
</a>
|
||||
· <a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change products" %}
|
||||
@@ -460,12 +468,12 @@
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
|
||||
<dt>{% trans "Attendee name" %}</dt>
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_emails_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>
|
||||
{% if line.attendee_email %}
|
||||
@@ -488,7 +496,7 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_company_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee company" %}
|
||||
</dt>
|
||||
@@ -496,7 +504,7 @@
|
||||
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_addresses_asked %}
|
||||
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
|
||||
<dt>
|
||||
{% trans "Attendee address" %}
|
||||
</dt>
|
||||
@@ -754,7 +762,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
{% if order.payment_refund_sum > 0 and "can_change_orders" in request.eventpermset %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
@@ -942,9 +950,11 @@
|
||||
{% bootstrap_field comment_form.comment show_help=True show_label=False %}
|
||||
{% bootstrap_field comment_form.custom_followup_at %}
|
||||
{% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %}
|
||||
<button class="btn btn-default">
|
||||
{% trans "Update comment" %}
|
||||
</button>
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
<button class="btn btn-default">
|
||||
{% trans "Update comment" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,35 +4,113 @@
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if "identifier" in request.GET %}
|
||||
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% for e in exporters %}
|
||||
<details class="panel panel-default"
|
||||
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ e.verbose_name }}
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="{{ e.identifier }}">
|
||||
<div class="panel-body">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='control' %}
|
||||
<button class="btn btn-primary pull-right flip" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start export" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:event.orders.export.scheduled.run" organizer=request.organizer.slug event=request.event.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if is_paginated %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% regroup exporters by category as category_list %}
|
||||
{% for c, c_ex in category_list %}
|
||||
{% if c %}
|
||||
<h2>{{ c }}</h2>
|
||||
{% else %}
|
||||
<h2>{% trans "Other exports" %}</h2>
|
||||
{% endif %}
|
||||
<div class="list-group large-link-group">
|
||||
{% for e in c_ex %}
|
||||
<a class="list-group-item" href="?identifier={{ e.identifier }}">
|
||||
<h4>
|
||||
{{ e.verbose_name }}
|
||||
{% if e.featured %}
|
||||
<span class="fa fa-star text-success" data-toggle="tooltip"
|
||||
title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if e.description %}
|
||||
<p>
|
||||
{{ e.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.orders.export" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if exporter %}
|
||||
<small>
|
||||
{{ exporter.verbose_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load captureas %}
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Schedule" %}</legend>
|
||||
{% bootstrap_field schedule_form.schedule_rrule_time layout='control' %}
|
||||
{% if schedule_form.timezone %}
|
||||
{% bootstrap_field schedule_form.timezone layout='control' %}
|
||||
{% endif %}
|
||||
{% bootstrap_form_errors rrule_form layout='control' %}
|
||||
|
||||
{% bootstrap_field rrule_form.dtstart layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">{% trans "Repetition schedule" %}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="form-inline rrule-form">
|
||||
{% captureas ffield_freq %}
|
||||
{% bootstrap_field rrule_form.freq layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_interval %}
|
||||
{% bootstrap_field rrule_form.interval layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_bysetpos %}
|
||||
{% bootstrap_field rrule_form.yearly_bysetpos layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_byweekday %}
|
||||
{% bootstrap_field rrule_form.yearly_byweekday layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_yearly_bymonth %}
|
||||
{% bootstrap_field rrule_form.yearly_bymonth layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_monthly_bysetpos %}
|
||||
{% bootstrap_field rrule_form.monthly_bysetpos layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_monthly_byweekday %}
|
||||
{% bootstrap_field rrule_form.monthly_byweekday layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_count %}
|
||||
{% bootstrap_field rrule_form.count layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
{% captureas ffield_until %}
|
||||
{% bootstrap_field rrule_form.until layout="inline" %}
|
||||
{% endcaptureas %}
|
||||
|
||||
{% blocktrans trimmed with freq=ffield_freq interval=ffield_interval start=ffield_dtstart %}
|
||||
Repeat every {{ interval }} {{ freq }}
|
||||
{% endblocktrans %}<br>
|
||||
|
||||
<div class="repeat-yearly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.0 }}
|
||||
{% trans "At the same date every year" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.yearly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_yearly_bysetpos weekday=ffield_yearly_byweekday month=ffield_yearly_bymonth %}
|
||||
On the {{ setpos }} {{ weekday }} of {{ month }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-monthly">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.0 }}
|
||||
{% trans "At the same date every month" %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.monthly_same.1 }}
|
||||
{% blocktrans trimmed with setpos=ffield_monthly_bysetpos weekday=ffield_monthly_byweekday %}
|
||||
On the {{ setpos }} {{ weekday }}
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repeat-weekly">
|
||||
{% bootstrap_field rrule_form.weekly_byweekday layout="inline" %}
|
||||
</div>
|
||||
<div class="repeat-until">
|
||||
<div class="radio">
|
||||
<label>
|
||||
{{ rrule_form.end.0 }}
|
||||
{% blocktrans trimmed with count=ffield_count %}
|
||||
Repeat for {{ count }} times
|
||||
{% endblocktrans %}
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.1 }}
|
||||
{% blocktrans trimmed with until=ffield_until %}
|
||||
Repeat until {{ until }}
|
||||
{% endblocktrans %}<br>
|
||||
</label><br>
|
||||
<label>
|
||||
{{ rrule_form.end.2 }}
|
||||
{% blocktrans trimmed %}
|
||||
Forever
|
||||
{% endblocktrans %}<br>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Email" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
{% trans "Every time your schedule is executed, the report will be sent via email." %}
|
||||
{% trans "Please note the following limitations:" %}
|
||||
<ul>
|
||||
<li>
|
||||
{% trans "Email is not a strongly encrypted medium. We only recommend using this for exports that output e.g. statistical data, not for reports that include sensitive personal data." %}
|
||||
</li>
|
||||
<li>
|
||||
{% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_schedule-owner">{% trans "Owner" %}</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" name="schedule-owner" value="{{ schedule_form.instance.owner.email }}" disabled
|
||||
class="form-control" title=""
|
||||
id="id_schedule-owner">
|
||||
<div class="help-block">
|
||||
{% trans "The export will be performed using the owner's permission level, i.e. if the owner loses access to the data, the report will stop." %}
|
||||
{% trans "The owner will receive the result as well as any error messages." %}
|
||||
{% trans "The additional recipients you add below will only receive an email if the report was successful." %}
|
||||
{% trans "All recipients of the export will be able to see who the owner of the report is." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients_cc layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_additional_recipients_bcc layout='control' %}
|
||||
{% bootstrap_field schedule_form.locale layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_subject layout='control' %}
|
||||
{% bootstrap_field schedule_form.mail_template layout='control' %}
|
||||
</fieldset>
|
||||
@@ -1,37 +1,110 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if "identifier" in request.GET %}
|
||||
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% for e in exporters %}
|
||||
<details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}>
|
||||
<summary class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{{ e.verbose_name }}
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
</h3>
|
||||
</summary>
|
||||
<div id="{{ e.identifier }}">
|
||||
<div class="panel-body">
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='control' %}
|
||||
<button class="btn btn-primary pull-right flip" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start export" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if scheduled %}
|
||||
<h2>{% trans "Scheduled exports" %}</h2>
|
||||
<ul class="list-group">
|
||||
{% for s in scheduled %}
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-5 col-md-4 col-xs-12">
|
||||
<span class="fa fa-fw fa-folder"></span>
|
||||
{{ s.export_verbose_name }}
|
||||
<br>
|
||||
<span class="text-muted">
|
||||
<span class="fa fa-fw fa-user"></span>
|
||||
{{ s.owner.fullname|default:s.owner.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
||||
{% if s.schedule_next_run %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "Next run:" %}
|
||||
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% trans "No next run scheduled" %}
|
||||
{% endif %}
|
||||
{% if s.export_verbose_name == "?" %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Exporter not found" %}
|
||||
</strong>
|
||||
{% elif s.error_counter >= 5 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Disabled due to multiple failures" %}
|
||||
</strong>
|
||||
{% elif s.error_counter > 0 %}
|
||||
<strong class="text-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed recently" %}
|
||||
</strong>
|
||||
{% endif %}
|
||||
<span class="text-muted">
|
||||
<br>
|
||||
<span class="fa fa-fw fa-envelope-o"></span>
|
||||
{{ s.mail_subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
|
||||
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
|
||||
{% if s.export_verbose_name != "?" %}
|
||||
<button type="submit" class="btn btn-default" title="{% trans "Run export now and download result" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-download"></span>
|
||||
</button>
|
||||
<button formaction="{% url "control:organizer.export.scheduled.run" organizer=request.organizer.slug pk=s.pk %}"
|
||||
type="submit"
|
||||
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
|
||||
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
|
||||
<span class="fa fa-play" aria-hidden="true"></span>
|
||||
</button>
|
||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if is_paginated %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% regroup exporters by category as category_list %}
|
||||
{% for c, c_ex in category_list %}
|
||||
{% if c %}
|
||||
<h2>{{ c }}</h2>
|
||||
{% else %}
|
||||
<h2>{% trans "Other exports" %}</h2>
|
||||
{% endif %}
|
||||
<div class="list-group large-link-group">
|
||||
{% for e in c_ex %}
|
||||
<a class="list-group-item" href="?identifier={{ e.identifier }}">
|
||||
<h4>{{ e.verbose_name }}</h4>
|
||||
{% if e.description %}
|
||||
<p>
|
||||
{{ e.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete scheduled export" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:organizer.export" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Data export" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Data export" %}
|
||||
{% if exporter %}
|
||||
<small>
|
||||
{{ exporter.verbose_name }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if exporter.description %}
|
||||
<p class="help-block">{{ exporter.description }}</p>
|
||||
{% endif %}
|
||||
{% if schedule_form %}
|
||||
{% bootstrap_form_errors schedule_form layout='control' %}
|
||||
{% endif %}
|
||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
|
||||
|
||||
<fieldset>
|
||||
<legend>{% trans "Export options" %}</legend>
|
||||
{% bootstrap_form exporter.form layout='control' %}
|
||||
</fieldset>
|
||||
{% if schedule_form %}
|
||||
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
|
||||
<div class="form-group submit-group">
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||
class="btn btn-primary btn-save" data-no-asynctask>
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-download" aria-hidden="true"></span>
|
||||
{% trans "Start export" %}
|
||||
</button>
|
||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
|
||||
class="btn btn-default btn-alternative" data-no-asynctask>
|
||||
<span class="fa fa-clock-o" aria-hidden="true"></span>
|
||||
{% trans "Schedule export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -458,7 +458,9 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
|
||||
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
|
||||
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">
|
||||
|
||||
@@ -207,6 +207,10 @@ urlpatterns = [
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/run$', organizer.RunScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.run'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.delete'),
|
||||
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
|
||||
re_path(r'^events/$', main.EventList.as_view(), name='events'),
|
||||
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
|
||||
@@ -386,6 +390,8 @@ urlpatterns = [
|
||||
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
|
||||
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
|
||||
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
|
||||
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),
|
||||
re_path(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
|
||||
re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
|
||||
re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
|
||||
|
||||
@@ -353,6 +353,12 @@ class Recover(TemplateView):
|
||||
user.save()
|
||||
messages.success(request, _('You can now login using your new password.'))
|
||||
user.log_action('pretix.control.auth.user.forgot_password.recovered')
|
||||
|
||||
has_redis = settings.HAS_REDIS
|
||||
if has_redis:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
rc.delete('pretix_pwreset_%s' % user.id)
|
||||
return redirect('control:auth.login')
|
||||
else:
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
import dateutil.parser
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
@@ -42,7 +43,7 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import is_aware, make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DeleteView, ListView
|
||||
from django.views.generic import ListView
|
||||
from pytz import UTC
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
@@ -56,6 +57,7 @@ from pretix.control.forms.filter import (
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.views import CreateView, PaginationMixin, UpdateView
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
|
||||
@@ -171,7 +173,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
|
||||
|
||||
class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView):
|
||||
template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
|
||||
permission = 'can_change_orders'
|
||||
permission = ('can_change_orders', 'can_checkin_orders')
|
||||
context_object_name = 'device'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
@@ -181,11 +183,16 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().prefetch_related(None).order_by()
|
||||
|
||||
def get_error_url(self):
|
||||
return self.get_success_url(None)
|
||||
|
||||
@transaction.atomic()
|
||||
def async_post(self, request, *args, **kwargs):
|
||||
self.list = get_object_or_404(request.event.checkin_lists.all(), pk=kwargs.get("list"))
|
||||
positions = self.get_queryset()
|
||||
if request.POST.get('revert') == 'true':
|
||||
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
|
||||
raise PermissionDenied()
|
||||
for op in positions:
|
||||
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
|
||||
Checkin.objects.filter(position=op, list=self.list).delete()
|
||||
@@ -388,7 +395,7 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class CheckinListDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class CheckinListDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = CheckinList
|
||||
template_name = 'pretixcontrol/checkin/list_delete.html'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
@@ -35,7 +35,6 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.edit import DeleteView
|
||||
|
||||
from pretix.base.models import CartPosition, Discount
|
||||
from pretix.control.forms.discounts import DiscountForm
|
||||
@@ -45,10 +44,11 @@ from pretix.control.permissions import (
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
from ...base.channels import get_all_sales_channels
|
||||
from ...helpers.compat import CompatDeleteView
|
||||
from . import CreateView, PaginationMixin, UpdateView
|
||||
|
||||
|
||||
class DiscountDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class DiscountDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Discount
|
||||
template_name = 'pretixcontrol/items/discount_delete.html'
|
||||
permission = 'can_change_items'
|
||||
|
||||
@@ -58,7 +58,7 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.generic import DeleteView, FormView, ListView
|
||||
from django.views.generic import FormView, ListView
|
||||
from django.views.generic.base import TemplateView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from i18nfield.strings import LazyI18nString
|
||||
@@ -94,6 +94,7 @@ from ...base.models.items import (
|
||||
Item, ItemCategory, ItemMetaProperty, Question, Quota,
|
||||
)
|
||||
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList
|
||||
from ...helpers.compat import CompatDeleteView
|
||||
from ...helpers.format import format_map
|
||||
from ..logdisplay import OVERVIEW_BANLIST
|
||||
from . import CreateView, PaginationMixin, UpdateView
|
||||
@@ -1243,7 +1244,7 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class TaxDelete(EventSettingsViewMixin, EventPermissionRequiredMixin, DeleteView):
|
||||
class TaxDelete(EventSettingsViewMixin, EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = TaxRule
|
||||
template_name = 'pretixcontrol/event/tax_delete.html'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
@@ -56,7 +56,6 @@ from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.detail import DetailView, SingleObjectMixin
|
||||
from django.views.generic.edit import DeleteView
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.api.serializers.item import (
|
||||
@@ -85,6 +84,7 @@ from pretix.control.signals import item_forms, item_formsets
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
from ...base.channels import get_all_sales_channels
|
||||
from ...helpers.compat import CompatDeleteView
|
||||
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
|
||||
|
||||
|
||||
@@ -189,9 +189,8 @@ def reorder_items(request, organizer, event):
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
class CategoryDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class CategoryDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = ItemCategory
|
||||
form_class = CategoryForm
|
||||
template_name = 'pretixcontrol/items/category_delete.html'
|
||||
permission = 'can_change_items'
|
||||
context_object_name = 'category'
|
||||
@@ -523,7 +522,7 @@ def reorder_questions(request, organizer, event):
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
class QuestionDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class QuestionDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Question
|
||||
template_name = 'pretixcontrol/items/question_delete.html'
|
||||
permission = 'can_change_items'
|
||||
@@ -1090,7 +1089,7 @@ class QuotaUpdate(EventPermissionRequiredMixin, UpdateView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class QuotaDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class QuotaDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Quota
|
||||
template_name = 'pretixcontrol/items/quota_delete.html'
|
||||
permission = 'can_change_items'
|
||||
@@ -1204,7 +1203,7 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
initial['tax_rule'] = trs[0]
|
||||
|
||||
if self.copy_from:
|
||||
fields = ('name', 'internal_name', 'category', 'admission', 'default_price', 'tax_rule')
|
||||
fields = ('name', 'internal_name', 'category', 'admission', 'personalized', 'default_price', 'tax_rule')
|
||||
for f in fields:
|
||||
initial[f] = getattr(self.copy_from, f)
|
||||
initial['copy_from'] = self.copy_from
|
||||
@@ -1464,7 +1463,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
|
||||
return f
|
||||
|
||||
|
||||
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class ItemDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Item
|
||||
template_name = 'pretixcontrol/item/delete.html'
|
||||
permission = 'can_change_items'
|
||||
|
||||
@@ -127,6 +127,9 @@ class OAuthApplicationDeleteView(ApplicationDelete):
|
||||
self.object.save()
|
||||
return HttpResponseRedirect(self.success_url)
|
||||
|
||||
def form_valid(self, form):
|
||||
return self.delete(self.request, self.args, self.kwargs)
|
||||
|
||||
|
||||
class AuthorizationListView(ListView):
|
||||
template_name = 'pretixcontrol/oauth/authorized.html'
|
||||
|
||||
@@ -77,7 +77,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
|
||||
InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
|
||||
Quota, generate_secret,
|
||||
Quota, ScheduledEventExport, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
@@ -87,7 +87,7 @@ from pretix.base.payment import PaymentException
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.cancelevent import cancel_event
|
||||
from pretix.base.services.export import export
|
||||
from pretix.base.services.export import export, scheduled_event_export
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
|
||||
invoice_qualified, regenerate_invoice,
|
||||
@@ -111,6 +111,7 @@ from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.base.views.mixins import OrderQuestionsViewMixin
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.control.forms.exports import ScheduledEventExportForm
|
||||
from pretix.control.forms.filter import (
|
||||
EventOrderExpertFilterForm, EventOrderFilterForm, OverviewFilterForm,
|
||||
RefundFilterForm,
|
||||
@@ -122,9 +123,11 @@ from pretix.control.forms.orders import (
|
||||
OrderPositionAddFormset, OrderPositionChangeForm, OrderPositionMailForm,
|
||||
OrderRefundForm, OtherOperationsForm, ReactivateOrderForm,
|
||||
)
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.signals import order_search_forms
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.safedownload import check_token
|
||||
from pretix.presale.signals import question_form_fields
|
||||
@@ -383,8 +386,8 @@ class OrderDetail(OrderView):
|
||||
|
||||
p.has_questions = (
|
||||
p.additional_fields or
|
||||
(p.item.admission and self.request.event.settings.attendee_names_asked) or
|
||||
(p.item.admission and self.request.event.settings.attendee_emails_asked) or
|
||||
(p.item.ask_attendee_data and self.request.event.settings.attendee_names_asked) or
|
||||
(p.item.ask_attendee_data and self.request.event.settings.attendee_emails_asked) or
|
||||
p.item.questions.all()
|
||||
)
|
||||
p.cache_answers()
|
||||
@@ -2237,20 +2240,40 @@ class OrderGo(EventPermissionRequiredMixin, View):
|
||||
class ExportMixin:
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
responses = register_data_exporters.send(self.request.event)
|
||||
id = self.request.GET.get("identifier") or self.request.POST.get("exporter")
|
||||
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||
if id and ex.identifier != id:
|
||||
return sorted(
|
||||
[response(self.request.event, self.request.organizer) for r, response in responses if response],
|
||||
key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def exporter(self):
|
||||
id = self.request.GET.get("identifier") or self.request.POST.get("exporter") or self.request.GET.get("exporter")
|
||||
if not id:
|
||||
return None
|
||||
for ex in self.exporters:
|
||||
if id != ex.identifier:
|
||||
continue
|
||||
|
||||
# Use form parse cycle to generate useful defaults
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
test_form.is_valid()
|
||||
initial = {
|
||||
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
|
||||
}
|
||||
if self.scheduled:
|
||||
initial = self.scheduled.export_form_data
|
||||
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
for k in initial:
|
||||
if initial[k] and k in test_form.fields:
|
||||
try:
|
||||
initial[k] = test_form.fields[k].to_python(initial[k])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Use form parse cycle to generate useful defaults
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
test_form.is_valid()
|
||||
initial = {
|
||||
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
|
||||
}
|
||||
|
||||
ex.form = ExporterForm(
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
@@ -2258,12 +2281,27 @@ class ExportMixin:
|
||||
initial=initial
|
||||
)
|
||||
ex.form.fields = ex.export_form_fields
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
return ex
|
||||
|
||||
def get_scheduled_queryset(self):
|
||||
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings',
|
||||
request=self.request):
|
||||
qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
|
||||
else:
|
||||
qs = self.request.event.scheduled_exports
|
||||
return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run')
|
||||
|
||||
@cached_property
|
||||
def scheduled(self):
|
||||
if "scheduled" in self.request.POST:
|
||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled"))
|
||||
elif "scheduled" in self.request.GET:
|
||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['exporters'] = self.exporters
|
||||
ctx['exporter'] = self.exporter
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -2271,7 +2309,7 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
|
||||
permission = 'can_view_orders'
|
||||
known_errortypes = ['ExportError']
|
||||
task = export
|
||||
template_name = 'pretixcontrol/orders/export.html'
|
||||
template_name = 'pretixcontrol/orders/export_form.html'
|
||||
|
||||
def get_success_message(self, value):
|
||||
return None
|
||||
@@ -2288,16 +2326,6 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
|
||||
def get_check_url(self, task_id, ajax):
|
||||
return self.request.path + '?async_id=%s&exporter=%s' % (task_id, self.exporter.identifier) + ('&ajax=1' if ajax else '')
|
||||
|
||||
@cached_property
|
||||
def exporter(self):
|
||||
if self.request.method == "POST":
|
||||
identifier = self.request.POST.get("exporter")
|
||||
else:
|
||||
identifier = self.request.GET.get("exporter")
|
||||
for ex in self.exporters:
|
||||
if ex.identifier == identifier:
|
||||
return ex
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'async_id' in request.GET and settings.HAS_CELERY:
|
||||
return self.get_result(request)
|
||||
@@ -2311,20 +2339,165 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, Templ
|
||||
'organizer': self.request.event.organizer.slug
|
||||
}))
|
||||
|
||||
if not self.exporter.form.is_valid():
|
||||
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
if self.scheduled:
|
||||
data = self.scheduled.export_form_data
|
||||
else:
|
||||
if not self.exporter.form.is_valid():
|
||||
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
data = self.exporter.form.cleaned_data
|
||||
|
||||
cf = CachedFile(web_download=True, session_key=request.session.session_key)
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=24)
|
||||
cf.save()
|
||||
return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, self.exporter.form.cleaned_data)
|
||||
return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, data)
|
||||
|
||||
|
||||
class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView):
|
||||
class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
|
||||
permission = 'can_view_orders'
|
||||
template_name = 'pretixcontrol/orders/export.html'
|
||||
paginate_by = 25
|
||||
context_object_name = 'scheduled'
|
||||
|
||||
def get_template_names(self):
|
||||
if self.exporter:
|
||||
return ['pretixcontrol/orders/export_form.html']
|
||||
return ['pretixcontrol/orders/export.html']
|
||||
|
||||
@transaction.atomic()
|
||||
def post(self, request, *args, **kwargs):
|
||||
if request.POST.get("schedule") == "save":
|
||||
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
||||
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
||||
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
||||
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
||||
self.schedule_form.instance.error_counter = 0
|
||||
self.schedule_form.instance.error_last_message = None
|
||||
self.schedule_form.instance.compute_next_run()
|
||||
self.schedule_form.instance.save()
|
||||
if self.schedule_form.instance.schedule_next_run:
|
||||
messages.success(
|
||||
request,
|
||||
_('Your export schedule has been saved. The next export will start around {datetime}.').format(
|
||||
datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT')
|
||||
)
|
||||
)
|
||||
else:
|
||||
messages.warning(request, _('Your export schedule has been saved, but no next export is planned.'))
|
||||
self.request.event.log_action(
|
||||
'pretix.event.export.schedule.changed' if self.scheduled else 'pretix.event.export.schedule.added',
|
||||
user=self.request.user, data={
|
||||
'id': self.schedule_form.instance.id,
|
||||
'export_identifier': self.exporter.identifier,
|
||||
'export_form_data': self.exporter.form.cleaned_data,
|
||||
'schedule_rrule': self.schedule_form.instance.schedule_rrule,
|
||||
**self.schedule_form.cleaned_data,
|
||||
}
|
||||
)
|
||||
return redirect(reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
}))
|
||||
else:
|
||||
return super().get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def rrule_form(self):
|
||||
if self.scheduled:
|
||||
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
||||
else:
|
||||
initial = {}
|
||||
return RRuleForm(
|
||||
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
|
||||
prefix="rrule",
|
||||
initial=initial
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def schedule_form(self):
|
||||
instance = self.scheduled or ScheduledEventExport(
|
||||
event=self.request.event,
|
||||
owner=self.request.user,
|
||||
)
|
||||
if not self.scheduled:
|
||||
initial = {
|
||||
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
||||
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
|
||||
name=str(self.request.event.name)
|
||||
),
|
||||
"schedule_rrule_time": time(4, 0, 0),
|
||||
}
|
||||
else:
|
||||
initial = {}
|
||||
return ScheduledEventExportForm(
|
||||
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
|
||||
prefix="schedule",
|
||||
instance=instance,
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.get_scheduled_queryset()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if "schedule" in self.request.POST or self.scheduled:
|
||||
ctx['schedule_form'] = self.schedule_form
|
||||
ctx['rrule_form'] = self.rrule_form
|
||||
elif not self.exporter:
|
||||
for s in ctx['scheduled']:
|
||||
try:
|
||||
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name
|
||||
except IndexError:
|
||||
s.export_verbose_name = "?"
|
||||
return ctx
|
||||
|
||||
|
||||
class DeleteScheduledExportView(EventPermissionRequiredMixin, ExportMixin, CompatDeleteView):
|
||||
permission = 'can_view_orders'
|
||||
template_name = 'pretixcontrol/orders/export_delete.html'
|
||||
context_object_name = 'export'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.get_scheduled_queryset()
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
|
||||
@transaction.atomic()
|
||||
def delete(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.object.delete()
|
||||
self.request.event.log_action('pretix.event.export.schedule.deleted', user=self.request.user, data={
|
||||
'id': self.object.id,
|
||||
})
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
class RunScheduledExportView(EventPermissionRequiredMixin, ExportMixin, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk'))
|
||||
scheduled_event_export.apply_async(
|
||||
kwargs={
|
||||
'event': s.event_id,
|
||||
'schedule': s.pk,
|
||||
},
|
||||
# Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered,
|
||||
# we run them with normal priority
|
||||
queue='default',
|
||||
)
|
||||
messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. '
|
||||
'Depending on system load and type and size of export, this may take a few '
|
||||
'minutes.'))
|
||||
return redirect(reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
}))
|
||||
|
||||
|
||||
class RefundList(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from datetime import time, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import bleach
|
||||
@@ -53,9 +53,10 @@ from django.forms import DecimalField
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import (
|
||||
CreateView, DeleteView, DetailView, FormView, ListView, TemplateView,
|
||||
@@ -71,7 +72,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
|
||||
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
|
||||
Team, TeamInvite, User,
|
||||
ScheduledOrganizerExport, Team, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
|
||||
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
|
||||
@@ -81,12 +82,13 @@ from pretix.base.models.giftcards import (
|
||||
from pretix.base.models.orders import CancellationRequest
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.export import multiexport
|
||||
from pretix.base.services.export import multiexport, scheduled_organizer_export
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import SETTINGS_AFFECTING_CSS
|
||||
from pretix.base.signals import register_multievent_data_exporters
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.control.forms.exports import ScheduledOrganizerExportForm
|
||||
from pretix.control.forms.filter import (
|
||||
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
|
||||
OrganizerFilterForm, TeamFilterForm,
|
||||
@@ -100,6 +102,7 @@ from pretix.control.forms.organizer import (
|
||||
OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm,
|
||||
TeamForm, WebHookForm,
|
||||
)
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.control.logdisplay import OVERVIEW_BANLIST
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
|
||||
@@ -108,6 +111,7 @@ from pretix.control.signals import nav_organizer
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.control.views.mailsetup import MailSettingsSetupView
|
||||
from pretix.helpers import GroupConcat
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||
@@ -623,7 +627,7 @@ class TeamUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class TeamDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class TeamDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Team
|
||||
template_name = 'pretixcontrol/organizers/team_delete.html'
|
||||
permission = 'can_change_teams'
|
||||
@@ -859,12 +863,19 @@ class DeviceQueryMixin:
|
||||
@cached_property
|
||||
def request_data(self):
|
||||
if self.request.method == "POST":
|
||||
return self.request.POST
|
||||
return self.request.GET
|
||||
d = self.request.POST
|
||||
else:
|
||||
d = self.request.GET
|
||||
d = d.copy()
|
||||
d.setdefault('state', 'active')
|
||||
return d
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return DeviceFilterForm(data=self.request_data, request=self.request)
|
||||
return DeviceFilterForm(
|
||||
data=self.request_data,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.organizer.devices.prefetch_related(
|
||||
@@ -1507,15 +1518,74 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
||||
|
||||
class ExportMixin:
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
events = self.request.user.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
def exporter(self):
|
||||
id = self.request.GET.get("identifier") or self.request.POST.get("exporter") or self.request.GET.get("exporter")
|
||||
if not id:
|
||||
return None
|
||||
for ex in self.exporters:
|
||||
if id != ex.identifier:
|
||||
continue
|
||||
if self.scheduled:
|
||||
initial = self.scheduled.export_form_data
|
||||
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
for k in initial:
|
||||
if initial[k] and k in test_form.fields:
|
||||
try:
|
||||
initial[k] = test_form.fields[k].to_python(initial[k])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Use form parse cycle to generate useful defaults
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
test_form.is_valid()
|
||||
initial = {
|
||||
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
|
||||
}
|
||||
if 'events' not in initial:
|
||||
initial.setdefault('all_events', True)
|
||||
|
||||
ex.form = ExporterForm(
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
prefix=ex.identifier,
|
||||
initial=initial
|
||||
)
|
||||
ex.form.fields = ex.export_form_fields
|
||||
if not isinstance(ex, OrganizerLevelExportMixin):
|
||||
ex.form.fields.update([
|
||||
('all_events',
|
||||
forms.BooleanField(
|
||||
label=_("All events (that I have access to)"),
|
||||
required=False
|
||||
)),
|
||||
('events',
|
||||
forms.ModelMultipleChoiceField(
|
||||
queryset=self.events,
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={
|
||||
'class': 'scrolling-multiple-choice',
|
||||
'data-inverse-dependency': f'#id_{ex.identifier}-all_events',
|
||||
}
|
||||
),
|
||||
label=_('Events'),
|
||||
required=False
|
||||
)),
|
||||
])
|
||||
return ex
|
||||
|
||||
@cached_property
|
||||
def events(self):
|
||||
return self.request.user.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
id = self.request.GET.get("identifier") or self.request.POST.get("exporter")
|
||||
raw_exporters = [
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, self.request.organizer)
|
||||
for r, response in responses
|
||||
if response
|
||||
]
|
||||
@@ -1526,50 +1596,37 @@ class ExportMixin:
|
||||
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
|
||||
|
||||
# Use form parse cycle to generate useful defaults
|
||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||
test_form.fields = ex.export_form_fields
|
||||
test_form.is_valid()
|
||||
initial = {
|
||||
k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET
|
||||
}
|
||||
|
||||
ex.form = ExporterForm(
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
prefix=ex.identifier,
|
||||
initial=initial
|
||||
)
|
||||
ex.form.fields = ex.export_form_fields
|
||||
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
|
||||
return sorted(
|
||||
raw_exporters,
|
||||
key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['exporter'] = self.exporter
|
||||
ctx['exporters'] = self.exporters
|
||||
return ctx
|
||||
|
||||
def get_scheduled_queryset(self):
|
||||
if not self.request.user.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings',
|
||||
request=self.request):
|
||||
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
|
||||
else:
|
||||
qs = self.request.organizer.scheduled_exports
|
||||
return qs.select_related('owner').order_by('export_identifier', 'schedule_next_run')
|
||||
|
||||
@cached_property
|
||||
def scheduled(self):
|
||||
if "scheduled" in self.request.POST:
|
||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.POST.get("scheduled"))
|
||||
elif "scheduled" in self.request.GET:
|
||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
||||
|
||||
|
||||
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
|
||||
known_errortypes = ['ExportError']
|
||||
task = multiexport
|
||||
template_name = 'pretixcontrol/organizers/export.html'
|
||||
template_name = 'pretixcontrol/organizers/export_form.html'
|
||||
|
||||
def get_success_message(self, value):
|
||||
return None
|
||||
@@ -1582,12 +1639,6 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
|
||||
'organizer': self.request.organizer.slug
|
||||
})
|
||||
|
||||
@cached_property
|
||||
def exporter(self):
|
||||
for ex in self.exporters:
|
||||
if ex.identifier == self.request.POST.get("exporter"):
|
||||
return ex
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'async_id' in request.GET and settings.HAS_CELERY:
|
||||
return self.get_result(request)
|
||||
@@ -1600,9 +1651,13 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
|
||||
'organizer': self.request.organizer.slug
|
||||
})
|
||||
|
||||
if not self.exporter.form.is_valid():
|
||||
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
if self.scheduled:
|
||||
data = self.scheduled.export_form_data
|
||||
else:
|
||||
if not self.exporter.form.is_valid():
|
||||
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
data = self.exporter.form.cleaned_data
|
||||
|
||||
cf = CachedFile(web_download=True, session_key=request.session.session_key)
|
||||
cf.date = now()
|
||||
@@ -1615,13 +1670,151 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
|
||||
provider=self.exporter.identifier,
|
||||
device=None,
|
||||
token=None,
|
||||
form_data=self.exporter.form.cleaned_data,
|
||||
form_data=data,
|
||||
staff_session=self.request.user.has_active_staff_session(self.request.session.session_key)
|
||||
)
|
||||
|
||||
|
||||
class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/organizers/export.html'
|
||||
class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
|
||||
paginate_by = 25
|
||||
context_object_name = 'scheduled'
|
||||
|
||||
def get_template_names(self):
|
||||
if self.exporter:
|
||||
return ['pretixcontrol/organizers/export_form.html']
|
||||
return ['pretixcontrol/organizers/export.html']
|
||||
|
||||
@transaction.atomic()
|
||||
def post(self, request, *args, **kwargs):
|
||||
if request.POST.get("schedule") == "save":
|
||||
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
||||
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
||||
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
||||
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
||||
self.schedule_form.instance.error_counter = 0
|
||||
self.schedule_form.instance.error_last_message = None
|
||||
self.schedule_form.instance.compute_next_run()
|
||||
self.schedule_form.instance.save()
|
||||
if self.schedule_form.instance.schedule_next_run:
|
||||
messages.success(
|
||||
request,
|
||||
_('Your export schedule has been saved. The next export will start around {datetime}.').format(
|
||||
datetime=date_format(self.schedule_form.instance.schedule_next_run, 'SHORT_DATETIME_FORMAT')
|
||||
)
|
||||
)
|
||||
else:
|
||||
messages.warning(request, _('Your export schedule has been saved, but no next export is planned.'))
|
||||
self.request.organizer.log_action(
|
||||
'pretix.organizer.export.schedule.changed' if self.scheduled else 'pretix.organizer.export.schedule.added',
|
||||
user=self.request.user, data={
|
||||
'id': self.schedule_form.instance.id,
|
||||
'export_identifier': self.exporter.identifier,
|
||||
'export_form_data': self.exporter.form.cleaned_data,
|
||||
'schedule_rrule': self.schedule_form.instance.schedule_rrule,
|
||||
**self.schedule_form.cleaned_data,
|
||||
}
|
||||
)
|
||||
return redirect(reverse('control:organizer.export', kwargs={
|
||||
'organizer': self.request.organizer.slug
|
||||
}))
|
||||
else:
|
||||
return super().get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def rrule_form(self):
|
||||
if self.scheduled:
|
||||
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
||||
else:
|
||||
initial = {}
|
||||
return RRuleForm(
|
||||
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
|
||||
prefix="rrule",
|
||||
initial=initial
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def schedule_form(self):
|
||||
instance = self.scheduled or ScheduledOrganizerExport(
|
||||
organizer=self.request.organizer,
|
||||
owner=self.request.user,
|
||||
timezone=get_current_timezone().zone,
|
||||
)
|
||||
if not self.scheduled:
|
||||
initial = {
|
||||
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
||||
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
|
||||
name=str(self.request.organizer.name)
|
||||
),
|
||||
"schedule_rrule_time": time(4, 0, 0),
|
||||
}
|
||||
else:
|
||||
initial = {}
|
||||
return ScheduledOrganizerExportForm(
|
||||
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
|
||||
prefix="schedule",
|
||||
instance=instance,
|
||||
initial=initial,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.get_scheduled_queryset()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if "schedule" in self.request.POST or self.scheduled:
|
||||
ctx['schedule_form'] = self.schedule_form
|
||||
ctx['rrule_form'] = self.rrule_form
|
||||
elif not self.exporter:
|
||||
for s in ctx['scheduled']:
|
||||
try:
|
||||
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name
|
||||
except IndexError:
|
||||
s.export_verbose_name = "?"
|
||||
return ctx
|
||||
|
||||
|
||||
class DeleteScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, DeleteView):
|
||||
template_name = 'pretixcontrol/organizers/export_delete.html'
|
||||
context_object_name = 'export'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.get_scheduled_queryset()
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.export', kwargs={
|
||||
'organizer': self.request.organizer.slug
|
||||
})
|
||||
|
||||
@transaction.atomic()
|
||||
def delete(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.object.delete()
|
||||
self.request.organizer.log_action('pretix.organizer.export.schedule.deleted', user=self.request.user, data={
|
||||
'id': self.object.id,
|
||||
})
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
class RunScheduledExportView(OrganizerPermissionRequiredMixin, ExportMixin, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
s = get_object_or_404(self.get_scheduled_queryset(), pk=kwargs.get('pk'))
|
||||
scheduled_organizer_export.apply_async(
|
||||
kwargs={
|
||||
'organizer': s.organizer_id,
|
||||
'schedule': s.pk,
|
||||
},
|
||||
# Scheduled exports usually run on the low-prio queue "background" but if they're manually triggered,
|
||||
# we run them with normal priority
|
||||
queue='default',
|
||||
)
|
||||
messages.success(self.request, _('Your export is queued to start soon. The results will be send via email. '
|
||||
'Depending on system load and type and size of export, this may take a few '
|
||||
'minutes.'))
|
||||
return redirect(reverse('control:organizer.export', kwargs={
|
||||
'organizer': self.request.organizer.slug
|
||||
}))
|
||||
|
||||
|
||||
class GateListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||
@@ -1701,7 +1894,7 @@ class GateUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class GateDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Gate
|
||||
template_name = 'pretixcontrol/organizers/gate_delete.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
@@ -1792,7 +1985,7 @@ class EventMetaPropertyUpdateView(OrganizerDetailViewMixin, OrganizerPermissionR
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class EventMetaPropertyDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = EventMetaProperty
|
||||
template_name = 'pretixcontrol/organizers/property_delete.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
@@ -1915,7 +2108,7 @@ class MembershipTypeUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = MembershipType
|
||||
template_name = 'pretixcontrol/organizers/membershiptype_delete.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
@@ -2028,7 +2221,7 @@ class SSOProviderUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequire
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class SSOProviderDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = CustomerSSOProvider
|
||||
template_name = 'pretixcontrol/organizers/ssoprovider_delete.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
@@ -2156,7 +2349,7 @@ class SSOClientUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredM
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class SSOClientDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
model = CustomerSSOClient
|
||||
template_name = 'pretixcontrol/organizers/ssoclient_delete.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
@@ -2419,7 +2612,7 @@ class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequired
|
||||
})
|
||||
|
||||
|
||||
class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
|
||||
class MembershipDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
|
||||
template_name = 'pretixcontrol/organizers/customer_membership_delete.html'
|
||||
permission = 'can_manage_customers'
|
||||
context_object_name = 'membership'
|
||||
|
||||
@@ -36,7 +36,7 @@ import copy
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
|
||||
from dateutil.rrule import rruleset
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
@@ -52,9 +52,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django.views import View
|
||||
from django.views.generic import (
|
||||
CreateView, DeleteView, FormView, ListView, UpdateView,
|
||||
)
|
||||
from django.views.generic import CreateView, FormView, ListView, UpdateView
|
||||
|
||||
from pretix.base.models import CartPosition, LogEntry
|
||||
from pretix.base.models.checkin import CheckinList
|
||||
@@ -80,6 +78,7 @@ from pretix.control.signals import subevent_forms
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.control.views.event import MetaDataEditorMixin
|
||||
from pretix.helpers import GroupConcat
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
|
||||
@@ -148,7 +147,7 @@ class SubEventList(EventPermissionRequiredMixin, PaginationMixin, SubEventQueryM
|
||||
return ctx
|
||||
|
||||
|
||||
class SubEventDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class SubEventDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = SubEvent
|
||||
template_name = 'pretixcontrol/subevents/delete.html'
|
||||
permission = 'can_change_settings'
|
||||
@@ -789,41 +788,10 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
|
||||
if f in self.rrule_formset.deleted_forms:
|
||||
continue
|
||||
|
||||
rule_kwargs = {}
|
||||
rule_kwargs['dtstart'] = f.cleaned_data['dtstart']
|
||||
rule_kwargs['interval'] = f.cleaned_data['interval']
|
||||
|
||||
if f.cleaned_data['freq'] == 'yearly':
|
||||
freq = YEARLY
|
||||
if f.cleaned_data['yearly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(f.cleaned_data['yearly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['yearly_byweekday'])
|
||||
rule_kwargs['bymonth'] = int(f.cleaned_data['yearly_bymonth'])
|
||||
|
||||
elif f.cleaned_data['freq'] == 'monthly':
|
||||
freq = MONTHLY
|
||||
|
||||
if f.cleaned_data['monthly_same'] == "off":
|
||||
rule_kwargs['bysetpos'] = int(f.cleaned_data['monthly_bysetpos'])
|
||||
rule_kwargs['byweekday'] = f.parse_weekdays(f.cleaned_data['monthly_byweekday'])
|
||||
elif f.cleaned_data['freq'] == 'weekly':
|
||||
freq = WEEKLY
|
||||
|
||||
if f.cleaned_data['weekly_byweekday']:
|
||||
rule_kwargs['byweekday'] = [f.parse_weekdays(a) for a in f.cleaned_data['weekly_byweekday']]
|
||||
|
||||
elif f.cleaned_data['freq'] == 'daily':
|
||||
freq = DAILY
|
||||
|
||||
if f.cleaned_data['end'] == 'count':
|
||||
rule_kwargs['count'] = f.cleaned_data['count']
|
||||
else:
|
||||
rule_kwargs['until'] = f.cleaned_data['until']
|
||||
|
||||
if f.cleaned_data['exclude']:
|
||||
s.exrule(rrule(freq, **rule_kwargs))
|
||||
s.exrule(f.to_rrule())
|
||||
else:
|
||||
s.rrule(rrule(freq, **rule_kwargs))
|
||||
s.rrule(f.to_rrule())
|
||||
|
||||
return s
|
||||
|
||||
|
||||
@@ -400,9 +400,13 @@ def items_select2(request, **kwargs):
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
qs = request.event.items.filter(
|
||||
name__icontains=i18ncomp(query)
|
||||
).order_by(
|
||||
q = Q(name__icontains=i18ncomp(query)) | Q(internal_name__icontains=query)
|
||||
try:
|
||||
if query.isdigit():
|
||||
q |= Q(pk=int(query))
|
||||
except ValueError:
|
||||
pass
|
||||
qs = request.event.items.filter(q).order_by(
|
||||
F('category__position').asc(nulls_first=True),
|
||||
'category',
|
||||
'position',
|
||||
|
||||
@@ -53,7 +53,7 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import (
|
||||
CreateView, DeleteView, ListView, TemplateView, UpdateView, View,
|
||||
CreateView, ListView, TemplateView, UpdateView, View,
|
||||
)
|
||||
|
||||
from pretix.base.models import CartPosition, LogEntry, Voucher
|
||||
@@ -66,6 +66,7 @@ from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.signals import voucher_form_class
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.helpers.compat import CompatDeleteView
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
|
||||
@@ -182,7 +183,7 @@ class VoucherTags(EventPermissionRequiredMixin, TemplateView):
|
||||
return VoucherTagFilterForm(data=self.request.GET, event=self.request.event)
|
||||
|
||||
|
||||
class VoucherDeleteCarts(EventPermissionRequiredMixin, DeleteView):
|
||||
class VoucherDeleteCarts(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Voucher
|
||||
template_name = 'pretixcontrol/vouchers/delete_carts.html'
|
||||
permission = 'can_change_vouchers'
|
||||
@@ -214,7 +215,7 @@ class VoucherDeleteCarts(EventPermissionRequiredMixin, DeleteView):
|
||||
})
|
||||
|
||||
|
||||
class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class VoucherDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = Voucher
|
||||
template_name = 'pretixcontrol/vouchers/delete.html'
|
||||
permission = 'can_change_vouchers'
|
||||
|
||||
@@ -48,7 +48,6 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.edit import DeleteView
|
||||
|
||||
from pretix.base.models import Item, Quota, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
@@ -58,6 +57,7 @@ from pretix.control.forms.waitinglist import WaitingListEntryTransferForm
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.control.views import PaginationMixin
|
||||
|
||||
from ...helpers.compat import CompatDeleteView
|
||||
from . import UpdateView
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class WaitingListView(EventPermissionRequiredMixin, WaitingListQuerySetMixin, Pa
|
||||
return '{}_waitinglist'.format(self.request.event.slug)
|
||||
|
||||
|
||||
class EntryDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
class EntryDelete(EventPermissionRequiredMixin, CompatDeleteView):
|
||||
model = WaitingListEntry
|
||||
template_name = 'pretixcontrol/waitinglist/delete.html'
|
||||
permission = 'can_change_orders'
|
||||
|
||||
@@ -22,9 +22,39 @@
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
from django.forms import Form
|
||||
from django.views.generic.detail import (
|
||||
BaseDetailView, SingleObjectTemplateResponseMixin,
|
||||
)
|
||||
from django.views.generic.edit import DeletionMixin, FormMixin
|
||||
|
||||
|
||||
def date_fromisocalendar(isoyear, isoweek, isoday):
|
||||
if sys.version_info < (3, 8):
|
||||
return datetime.datetime.strptime(f'{isoyear}-W{isoweek}-{isoday}', "%G-W%V-%u")
|
||||
else:
|
||||
return datetime.datetime.fromisocalendar(isoyear, isoweek, isoday)
|
||||
|
||||
|
||||
class CompatDeleteView(SingleObjectTemplateResponseMixin, DeletionMixin, FormMixin, BaseDetailView):
|
||||
"""
|
||||
This is a DeleteView variant that allows us to create subclasses that work on both Django 3.2 and
|
||||
Django 4.0.
|
||||
"""
|
||||
form_class = Form
|
||||
template_name_suffix = "_confirm_delete"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Set self.object before the usual form processing flow.
|
||||
# Inlined because having DeletionMixin as the first base, for
|
||||
# get_success_url(), makes leveraging super() with ProcessFormView
|
||||
# overly complex.
|
||||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
return self.delete(self.request, self.args, self.kwargs)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Aggregate, Field, Lookup
|
||||
from django.db.models import Aggregate, Expression, Field, Lookup, Value
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
@@ -103,3 +103,42 @@ class NotEqual(Lookup):
|
||||
rhs, rhs_params = self.process_rhs(compiler, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return '%s <> %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
class PostgresWindowFrame(Expression):
|
||||
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
|
||||
|
||||
def __init__(self, frame_type=None, start=None, end=None):
|
||||
self.frame_type = frame_type
|
||||
self.start = Value(start)
|
||||
self.end = Value(end)
|
||||
|
||||
def set_source_expressions(self, exprs):
|
||||
self.start, self.end = exprs
|
||||
|
||||
def get_source_expressions(self):
|
||||
return [self.start, self.end]
|
||||
|
||||
def as_sql(self, compiler, connection):
|
||||
return (
|
||||
self.template
|
||||
% {
|
||||
"frame_type": self.frame_type,
|
||||
"start": self.start.value,
|
||||
"end": self.end.value,
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self)
|
||||
|
||||
def get_group_by_cols(self, alias=None):
|
||||
return []
|
||||
|
||||
def __str__(self):
|
||||
return self.template % {
|
||||
"frame_type": self.frame_type,
|
||||
"start": self.start.value,
|
||||
"end": self.end.value,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from PIL.Image import MAX_IMAGE_PIXELS, DecompressionBombError
|
||||
|
||||
IMAGE_TYPES = {'image/gif', 'image/jpeg', 'image/png'}
|
||||
IMAGE_EXTS = {'.gif', '.jpg', '.jpeg', '.png'}
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_uploaded_file_for_valid_image(f):
|
||||
if f is None:
|
||||
return None
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# We need to get a file object for Pillow. We might have a path or we might
|
||||
# have to read the data into memory.
|
||||
if hasattr(f, 'temporary_file_path'):
|
||||
file = f.temporary_file_path()
|
||||
else:
|
||||
if hasattr(f, 'read'):
|
||||
file = BytesIO(f.read())
|
||||
else:
|
||||
file = BytesIO(f['content'])
|
||||
|
||||
try:
|
||||
try:
|
||||
image = Image.open(file)
|
||||
# verify() must be called immediately after the constructor.
|
||||
image.verify()
|
||||
except DecompressionBombError:
|
||||
raise ValidationError(_(
|
||||
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
|
||||
))
|
||||
|
||||
# load() is a potential DoS vector (see Django bug #18520), so we verify the size first
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValidationError(_(
|
||||
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.exception('Could not parse image')
|
||||
# Pillow doesn't recognize it as an image.
|
||||
if isinstance(exc, ValidationError):
|
||||
raise
|
||||
raise ValidationError(_(
|
||||
"Upload a valid image. The file you uploaded was either not an image or a corrupted image."
|
||||
)) from exc
|
||||
if hasattr(f, 'seek') and callable(f.seek):
|
||||
f.seek(0)
|
||||
|
||||
|
||||
class ImageSizeValidator:
|
||||
def __call__(self, image):
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise ValidationError(_(
|
||||
"The file you uploaded has a very large number of pixels, please upload a picture with smaller dimensions."
|
||||
))
|
||||
return image
|
||||
+3575
-2487
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-12-14 13:09+0000\n"
|
||||
"POT-Creation-Date: 2023-01-19 10:47+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/"
|
||||
@@ -132,18 +132,18 @@ msgstr ""
|
||||
msgid "Mercado Pago"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:163
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:164
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:48
|
||||
msgid "Continue"
|
||||
msgstr "المتابعة"
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:221
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:152
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:183
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:222
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:157
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:188
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "جاري تأكيد الدفع الخاص بك …"
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:246
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:247
|
||||
msgid "Payment method unavailable"
|
||||
msgstr ""
|
||||
|
||||
@@ -165,11 +165,11 @@ msgstr "إجمالي الإيرادات"
|
||||
msgid "Contacting Stripe …"
|
||||
msgstr "جاري الاتصال بStripe …"
|
||||
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:60
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:65
|
||||
msgid "Total"
|
||||
msgstr "المجموع"
|
||||
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:159
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:164
|
||||
msgid "Contacting your bank …"
|
||||
msgstr "جاري الاتصال بالبنك الذي تتعامل معه …"
|
||||
|
||||
@@ -367,11 +367,11 @@ msgid ""
|
||||
msgstr ""
|
||||
"لا يمكننا الوصول إلى الخادم حاليا، حاول مرة أخرى من فضلك. رمز الخطأ : {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:208
|
||||
#: pretix/static/pretixbase/js/asynctask.js:215
|
||||
msgid "We are processing your request …"
|
||||
msgstr "جاري معالجة طلبك …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:216
|
||||
#: pretix/static/pretixbase/js/asynctask.js:223
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
@@ -380,7 +380,7 @@ msgstr ""
|
||||
"نعمل الآن على ارسال طلبك إلى الخادم، إذا أستغرقت العملية أكثر من دقيقة، يرجى "
|
||||
"التحقق من اتصالك بالإنترنت ثم أعد تحميل الصفحة مرة أخرى."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:276
|
||||
#: pretix/static/pretixbase/js/asynctask.js:285
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:71
|
||||
msgid "Close message"
|
||||
msgstr "أغلق الرسالة"
|
||||
@@ -486,48 +486,48 @@ msgstr "الدقائق"
|
||||
msgid "Check-in QR"
|
||||
msgstr "QR الدخول"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:384
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:385
|
||||
msgid "The PDF background file could not be loaded for the following reason:"
|
||||
msgstr "لا يمكن تحميل ملف PDF الخلفية للأسباب التالية:"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:653
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:654
|
||||
msgid "Group of objects"
|
||||
msgstr "مجموعة من العناصر"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:659
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:660
|
||||
msgid "Text object"
|
||||
msgstr "عنصر نص"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:661
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:662
|
||||
msgid "Barcode area"
|
||||
msgstr "منطقة باركود"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:663
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:664
|
||||
msgid "Image area"
|
||||
msgstr "منطقة صورة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:665
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:666
|
||||
msgid "Powered by pretix"
|
||||
msgstr "مدعوم من pretix"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:667
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:668
|
||||
msgid "Object"
|
||||
msgstr "عنصر"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:671
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:672
|
||||
msgid "Ticket design"
|
||||
msgstr "تصميم التذكرة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:961
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:962
|
||||
msgid "Saving failed."
|
||||
msgstr "فشلت عملية الحفظ."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1011
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1050
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1030
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1069
|
||||
msgid "Error while uploading your PDF file, please try again."
|
||||
msgstr "حصل خطأ أثناء رفع ملف PDF الخاص بك، يرجى المحاولة مرة أخرى."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1035
|
||||
#: pretix/static/pretixcontrol/js/ui/editor.js:1054
|
||||
msgid "Do you really want to leave the editor without saving your changes?"
|
||||
msgstr "هل تريد أن تغادر المحرر دون حفظ التعديلات؟"
|
||||
|
||||
@@ -557,31 +557,31 @@ msgid ""
|
||||
"darker shade."
|
||||
msgstr "تباين اللون سيئ للخلفية البيضاء، الرجاء اختيار لون غامق."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:454
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:463
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:455
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:464
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:456
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:465
|
||||
msgid "Search query"
|
||||
msgstr "البحث في الاستفسارات"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:459
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:468
|
||||
msgid "Selected only"
|
||||
msgstr "المختارة فقط"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:862
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:871
|
||||
msgid "Use a different name internally"
|
||||
msgstr "قم باستخدم اسم مختلف داخليا"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:898
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:907
|
||||
msgid "Click to close"
|
||||
msgstr "اضغط لاغلاق الصفحة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:970
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:979
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "لم تقم بحفظ التعديلات!"
|
||||
|
||||
@@ -866,85 +866,93 @@ msgid "Open seat selection"
|
||||
msgstr "خيارات المقاعد"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:56
|
||||
msgctxt "widget"
|
||||
msgid ""
|
||||
"Some or all ticket categories are currently sold out. If you want, you can "
|
||||
"add yourself to the waiting list. We will then notify if seats are available "
|
||||
"again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:57
|
||||
#, fuzzy
|
||||
#| msgid "Load more"
|
||||
msgctxt "widget"
|
||||
msgid "Load more"
|
||||
msgstr "تحميل المزيد"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:58
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:59
|
||||
msgid "Mo"
|
||||
msgstr "الإثنين"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:59
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:60
|
||||
msgid "Tu"
|
||||
msgstr "الثلاثاء"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:60
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:61
|
||||
msgid "We"
|
||||
msgstr "الأربعاء"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:61
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:62
|
||||
msgid "Th"
|
||||
msgstr "الخميس"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:62
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:63
|
||||
msgid "Fr"
|
||||
msgstr "الجمعة"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:63
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:64
|
||||
msgid "Sa"
|
||||
msgstr "السبت"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:64
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:65
|
||||
msgid "Su"
|
||||
msgstr "الأحد"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:67
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:68
|
||||
msgid "January"
|
||||
msgstr "يناير"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:68
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:69
|
||||
msgid "February"
|
||||
msgstr "فبراير"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:69
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:70
|
||||
msgid "March"
|
||||
msgstr "مارس"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:70
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:71
|
||||
msgid "April"
|
||||
msgstr "أبريل"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:71
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:72
|
||||
msgid "May"
|
||||
msgstr "مايو"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:72
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:73
|
||||
msgid "June"
|
||||
msgstr "يونيو"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:73
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:74
|
||||
msgid "July"
|
||||
msgstr "يوليو"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:74
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:75
|
||||
msgid "August"
|
||||
msgstr "أغسطس"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:75
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:76
|
||||
msgid "September"
|
||||
msgstr "سبتمبر"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:76
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:77
|
||||
msgid "October"
|
||||
msgstr "أكتوبر"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:77
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:78
|
||||
msgid "November"
|
||||
msgstr "نوفمبر"
|
||||
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:78
|
||||
#: pretix/static/pretixpresale/js/widget/widget.js:79
|
||||
msgid "December"
|
||||
msgstr "ديسمبر"
|
||||
|
||||
|
||||
+3515
-2494
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user