forked from CGM_Public/pretix_original
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d745bcf2c4 | ||
|
|
a1bfe05879 | ||
|
|
f156299cb3 | ||
|
|
023b1535d4 | ||
|
|
ec97dae695 | ||
|
|
f184ca1918 | ||
|
|
7f71ae6e4b | ||
|
|
84bafd94d5 | ||
|
|
ac7502b0a2 | ||
|
|
3c85591568 | ||
|
|
2787935fc6 | ||
|
|
6d432cf824 | ||
|
|
e09853c6c6 | ||
|
|
418c9196ba | ||
|
|
a949fd7fdc | ||
|
|
f9b834b798 | ||
|
|
0747f5b8b8 | ||
|
|
33b34f31d1 | ||
|
|
f93c780e6a | ||
|
|
9722e76e5f | ||
|
|
e33d15429e | ||
|
|
41c69aaa2a | ||
|
|
07ed7526c0 | ||
|
|
1043824853 | ||
|
|
a99a254f5c | ||
|
|
0429a0f811 | ||
|
|
c2ba312bad | ||
|
|
a3ff3cda12 | ||
|
|
aeba2a1e26 | ||
|
|
e57291914c | ||
|
|
7165cc4c3b | ||
|
|
fa5f33d3c6 | ||
|
|
c8df9c187e | ||
|
|
35270e7032 | ||
|
|
898ae3e2bc | ||
|
|
76d0c7be3a | ||
|
|
793832402c | ||
|
|
f6a500cd75 | ||
|
|
7a8f90478a | ||
|
|
6ea4315beb | ||
|
|
f3de5d5c96 | ||
|
|
fdc555f74f | ||
|
|
2505389e61 | ||
|
|
da38396191 | ||
|
|
2abe744bdd | ||
|
|
ce79bfb242 | ||
|
|
748cfa3487 | ||
|
|
eb80cf248e | ||
|
|
65e3efa5a3 | ||
|
|
3388c3ab09 | ||
|
|
65ff065f02 | ||
|
|
0f30958937 | ||
|
|
5cef80d58c | ||
|
|
19c328b6e7 | ||
|
|
fc6b644587 | ||
|
|
190ffe8d24 | ||
|
|
18eedd8a5f | ||
|
|
00667aff11 | ||
|
|
f1cd46f6dc | ||
|
|
674d7673ce | ||
|
|
71800074ca | ||
|
|
a7b331a9b0 | ||
|
|
1d541df381 | ||
|
|
32d32d68d9 | ||
|
|
5375f6aec1 | ||
|
|
99f3360c44 | ||
|
|
d391312aab | ||
|
|
70bf422537 | ||
|
|
86932e8a19 | ||
|
|
2d9bf5ecb9 | ||
|
|
c4e8da8ea4 | ||
|
|
715fdadf95 | ||
|
|
1b53d74aa9 | ||
|
|
66621aee6e | ||
|
|
18333041bb | ||
|
|
b4badaa472 | ||
|
|
a856f29426 | ||
|
|
1dab5149d4 | ||
|
|
4e870b7366 | ||
|
|
a8cbb06bb0 | ||
|
|
0be2043ded | ||
|
|
2554c7f5fc | ||
|
|
3912ceb79d | ||
|
|
593fc69d0c | ||
|
|
cf3c4d26cb | ||
|
|
bc8358cd97 | ||
|
|
e2461ab475 | ||
|
|
f97c97e661 | ||
|
|
1325cf1e7c | ||
|
|
ba8ea0e4d4 | ||
|
|
1c769f2876 | ||
|
|
2dee222482 | ||
|
|
d132cd27f3 | ||
|
|
9a2a4bedeb | ||
|
|
779cefeaad | ||
|
|
b36feb229f | ||
|
|
2e5861958d | ||
|
|
01c3b08583 | ||
|
|
5b81507600 | ||
|
|
75e100f108 | ||
|
|
8b08b43e77 | ||
|
|
9d70fd675c | ||
|
|
72504cd53a | ||
|
|
9056826b68 | ||
|
|
ecf05b2392 | ||
|
|
4aa9f073b3 | ||
|
|
19c2b8d89d | ||
|
|
5e355b4005 | ||
|
|
746c140cdb | ||
|
|
be413693ce | ||
|
|
6cf1074b8d | ||
|
|
504067f325 | ||
|
|
b1cffe9f72 | ||
|
|
c0dd631774 | ||
|
|
66cd63036c | ||
|
|
29a45d3ee4 | ||
|
|
23aba9b5ef | ||
|
|
454f0f6fc8 | ||
|
|
002ff38fba | ||
|
|
dc8bd59715 | ||
|
|
56a2da08df | ||
|
|
4762d6818f | ||
|
|
e99e91d20f | ||
|
|
9fee2d0fbc | ||
|
|
3f30ddc9ab | ||
|
|
641a848f30 |
@@ -135,7 +135,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
user=pretix
|
||||
; Replace with the password you chose above
|
||||
password=*********
|
||||
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjuts
|
||||
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust
|
||||
; this to wherever your database is running, e.g. the name of a linked container
|
||||
; or of a mounted MySQL socket.
|
||||
host=172.17.0.1
|
||||
|
||||
@@ -95,6 +95,12 @@ pretix_model_instances
|
||||
the ``model`` name. Starting with pretix 3.11, these numbers might only be approximate for
|
||||
most tables when running on PostgreSQL to mitigate performance impact.
|
||||
|
||||
pretix_celery_tasks_queued_count
|
||||
The number of background tasks in the worker queue, labeled with ``queue``.
|
||||
|
||||
pretix_celery_tasks_queued_age_seconds
|
||||
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
|
||||
@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
||||
constructed from a number of
|
||||
days before the base point
|
||||
and the base point.
|
||||
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||
specifiers in requests
|
||||
(see below).
|
||||
===================== ============================ ===================================
|
||||
|
||||
Query parameters
|
||||
@@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte
|
||||
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
|
||||
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
|
||||
|
||||
|
||||
File upload
|
||||
-----------
|
||||
|
||||
In some places, the API supports working with files, for example when setting the picture of a product. In this case,
|
||||
you will first need to make a separate request to our file upload endpoint:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/upload HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
Content-Type: image/png
|
||||
Content-Disposition: attachment; filename="logo.png"
|
||||
Content-Length: 1234
|
||||
|
||||
<raw file content>
|
||||
|
||||
Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will
|
||||
receive a JSON response with the ID of the file:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||
}
|
||||
|
||||
You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only
|
||||
be used using the same authorization method and user that was used to upload them.
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
|
||||
}
|
||||
|
||||
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
|
||||
@@ -36,8 +36,8 @@ admission boolean ``true`` for it
|
||||
(such as primary tickets) and ``false`` for others
|
||||
(such as add-ons or merchandise).
|
||||
position integer An integer, used for sorting
|
||||
picture string A product picture to be displayed in the shop
|
||||
(read-only, can be ``null``).
|
||||
picture file A product picture to be displayed in the shop
|
||||
(can be ``null``).
|
||||
sales_channels list of strings Sales channels this product is available on, such as
|
||||
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
|
||||
available_from datetime The first date time at which this item can be bought
|
||||
|
||||
@@ -325,7 +325,8 @@ state string Payment state,
|
||||
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
|
||||
amount money (string) Payment amount
|
||||
created datetime Date and time of creation of this payment
|
||||
payment_date datetime Date and time of completion of this payment (or ``null``)
|
||||
comment string Reason for refund (shown to the customer in some cases, can be ``null``).
|
||||
execution_date datetime Date and time of completion of this refund (or ``null``)
|
||||
provider string Identification string of the payment provider
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -1706,6 +1707,67 @@ Order position ticket download
|
||||
Manipulating individual positions
|
||||
---------------------------------
|
||||
|
||||
.. versionchanged:: 3.15
|
||||
|
||||
The ``PATCH`` method has been added for individual positions.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||
|
||||
Updates specific fields on an order position. Currently, only the following fields are supported:
|
||||
|
||||
* ``attendee_email``
|
||||
|
||||
* ``attendee_name_parts`` or ``attendee_name``
|
||||
|
||||
* ``company``
|
||||
|
||||
* ``street``
|
||||
|
||||
* ``zipcode``
|
||||
|
||||
* ``city``
|
||||
|
||||
* ``country``
|
||||
|
||||
* ``state``
|
||||
|
||||
* ``answers``: If specified, you will need to provide **all** answers for this order position.
|
||||
Validation is handled the same way as when creating orders through the API. You are therefore
|
||||
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
|
||||
and ``option_identifiers`` will be ignored.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"attendee_email": "other@example.org"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
(Full order resource, see above.)
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event
|
||||
:param event: The ``slug`` field of the event
|
||||
:param id: The ``id`` field of the order position to update
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order could not be updated due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
|
||||
|
||||
Deletes an order position, identified by its internal ID.
|
||||
@@ -2058,6 +2120,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
]
|
||||
@@ -2100,6 +2163,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": "2017-12-04T12:13:12Z",
|
||||
"comment": "Cancellation",
|
||||
"provider": "banktransfer"
|
||||
}
|
||||
|
||||
@@ -2134,6 +2198,7 @@ Order refund endpoints
|
||||
"amount": "23.00",
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"comment": "Cancellation",
|
||||
"provider": "manual",
|
||||
"mark_canceled": false,
|
||||
"mark_pending": true
|
||||
@@ -2155,6 +2220,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
"execution_date": null,
|
||||
"comment": "Cancellation",
|
||||
"provider": "manual"
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ Control panel views
|
||||
-------------------
|
||||
|
||||
If you want to add a custom view to the control area of an event, just register an URL in your
|
||||
``urls.py`` that lives in the ``/control/`` subpath::
|
||||
``urls.py`` that lives in the ``/control/`` subpath:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
@@ -44,7 +46,9 @@ If only the ``organizer`` parameter is present, it will be ensured that:
|
||||
* The user has permission to access view the current organizer
|
||||
|
||||
If you want to require specific permission types, we provide you with a decorator or a mixin for
|
||||
your views::
|
||||
your views:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.control.permissions import (
|
||||
event_permission_required, EventPermissionRequiredMixin
|
||||
@@ -61,8 +65,9 @@ your views::
|
||||
...
|
||||
|
||||
Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionRequiredMixin``. In case of
|
||||
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
|
||||
event-related views, there is also a signal that allows you to add the view to the event navigation like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.urls import resolve, reverse
|
||||
from django.dispatch import receiver
|
||||
@@ -90,7 +95,9 @@ Event settings view
|
||||
-------------------
|
||||
|
||||
A special case of a control panel view is a view hooked into the event settings page. For this case, there is a
|
||||
special navigation signal::
|
||||
special navigation signal:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(nav_event_settings, dispatch_uid='friends_tickets_nav_settings')
|
||||
def navbar_settings(sender, request, **kwargs):
|
||||
@@ -105,7 +112,9 @@ special navigation signal::
|
||||
}]
|
||||
|
||||
Also, your view should inherit from ``EventSettingsViewMixin`` and your template from ``pretixcontrol/event/settings_base.html``
|
||||
for good integration. If you just want to display a form, you could do it like the following::
|
||||
for good integration. If you just want to display a form, you could do it like the following:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
|
||||
model = Event
|
||||
@@ -147,7 +156,9 @@ Including a custom view into the participant-facing frontend is a little bit dif
|
||||
no path prefix like ``control/``.
|
||||
|
||||
First, define your URL in your ``urls.py``, but this time in the ``event_patterns`` section and wrapped by
|
||||
``event_url``::
|
||||
``event_url``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.multidomain import event_url
|
||||
|
||||
@@ -182,8 +193,9 @@ standard Django request handling: There are `ViewSets`_ to group related views i
|
||||
automatically build URL configurations from them.
|
||||
|
||||
To integrate a custom viewset with pretix' REST API, you can just register with one of our routers within the
|
||||
``urls.py`` module of your plugin::
|
||||
``urls.py`` module of your plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.api.urls import event_router, router, orga_router
|
||||
|
||||
@@ -200,7 +212,9 @@ in the control panel. However, you need to make sure on your own only to return
|
||||
.event`` and ``request.organizer`` are available as usual.
|
||||
|
||||
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
|
||||
class, you can just set the ``permission`` attribute on your viewset::
|
||||
class, you can just set the ``permission`` attribute on your viewset:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyViewSet(ModelViewSet):
|
||||
permission = 'can_view_orders'
|
||||
@@ -208,8 +222,9 @@ class, you can just set the ``permission`` attribute on your viewset::
|
||||
|
||||
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
|
||||
API authentications can be done via user sessions or API tokens and you should therefore check something like the
|
||||
following::
|
||||
following:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
||||
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
|
||||
|
||||
@@ -15,7 +15,9 @@ Output registration
|
||||
The email HTML renderer API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available email renderers. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
|
||||
that we'll provide in this plugin::
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
@@ -72,7 +74,9 @@ class ``TemplateBasedMailRenderer`` that you can re-use to perform the following
|
||||
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
|
||||
attributes for better compatibility
|
||||
|
||||
To use it, you just need to implement some variables::
|
||||
To use it, you just need to implement some variables:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class ClassicMailRenderer(TemplateBasedMailRenderer):
|
||||
verbose_name = _('pretix default')
|
||||
|
||||
@@ -17,7 +17,9 @@ Exporter registration
|
||||
The exporter API does not make a lot of usage from signals, however, it does use a signal to get a list of
|
||||
all available exporters. Your plugin should listen for this signal and return the subclass of
|
||||
``pretix.base.exporter.BaseExporter``
|
||||
that we'll provide in this plugin::
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
@@ -31,7 +33,9 @@ that we'll provide in this plugin::
|
||||
|
||||
Some exporters might also prove to be useful, when provided on an organizer-level. In order to declare your
|
||||
exporter as capable of providing exports spanning multiple events, your plugin should listen for this signal
|
||||
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin::
|
||||
and return the subclass of ``pretix.base.exporter.BaseExporter`` that we'll provide in this plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ Output registration
|
||||
The invoice renderer API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available invoice renderers. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.invoice.BaseInvoiceRenderer``
|
||||
that we'll provide in this plugin::
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ Provider registration
|
||||
The payment provider API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available payment providers. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.payment.BasePaymentProvider``
|
||||
that the plugin will provide::
|
||||
that the plugin will provide:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
@@ -104,14 +106,22 @@ The provider class
|
||||
|
||||
.. automethod:: payment_control_render
|
||||
|
||||
.. automethod:: payment_control_render_short
|
||||
|
||||
.. automethod:: payment_refund_supported
|
||||
|
||||
.. automethod:: payment_partial_refund_supported
|
||||
|
||||
.. automethod:: payment_presale_render
|
||||
|
||||
.. automethod:: execute_refund
|
||||
|
||||
.. automethod:: refund_control_render
|
||||
|
||||
.. automethod:: new_refund_control_form_render
|
||||
|
||||
.. automethod:: new_refund_control_form_process
|
||||
|
||||
.. automethod:: api_payment_details
|
||||
|
||||
.. automethod:: matching_id
|
||||
@@ -140,7 +150,9 @@ it is necessary to introduce additional views. One example is the PayPal
|
||||
provider. It redirects the user to a PayPal website in the
|
||||
:py:meth:`BasePaymentProvider.checkout_prepare` step of the checkout process
|
||||
and provides PayPal with a URL to redirect back to. This URL points to a
|
||||
view which looks roughly like this::
|
||||
view which looks roughly like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@login_required
|
||||
def success(request):
|
||||
|
||||
@@ -13,7 +13,9 @@ Placeholder registration
|
||||
|
||||
The placeholder API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available email placeholders. Your plugin
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
|
||||
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
@@ -71,7 +73,9 @@ Helper class for simple placeholders
|
||||
------------------------------------
|
||||
|
||||
pretix ships with a helper class that makes it easy to provide placeholders based on simple
|
||||
functions::
|
||||
functions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
placeholder = SimpleFunctionalMailTextPlaceholder(
|
||||
'code', ['order'], lambda order: order.code, sample='F8VVL'
|
||||
|
||||
@@ -55,7 +55,9 @@ restricted boolean (optional) ``False`` by default, restricts a plugin
|
||||
compatibility string Specifier for compatible pretix versions.
|
||||
================== ==================== ===========================================================
|
||||
|
||||
A working example would be::
|
||||
A working example would be:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
from pretix.base.plugins import PluginConfig
|
||||
@@ -81,7 +83,7 @@ A working example would be::
|
||||
|
||||
default_app_config = 'pretix_paypal.PaypalApp'
|
||||
|
||||
The ``AppConfig`` class may implement a property ``compatiblity_errors``, that checks
|
||||
The ``AppConfig`` class may implement a property ``compatibility_errors``, that checks
|
||||
whether the pretix installation meets all requirements of the plugin. If so,
|
||||
it should contain ``None`` or an empty list, otherwise a list of strings containing
|
||||
human-readable error messages. We recommend using the ``django.utils.functional.cached_property``
|
||||
@@ -96,7 +98,9 @@ Plugin registration
|
||||
|
||||
Somehow, pretix needs to know that your plugin exists at all. For this purpose, we
|
||||
make use of the `entry point`_ feature of setuptools. To register a plugin that lives
|
||||
in a separate python package, your ``setup.py`` should contain something like this::
|
||||
in a separate python package, your ``setup.py`` should contain something like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
setup(
|
||||
args...,
|
||||
@@ -118,7 +122,9 @@ The various components of pretix define a number of signals which your plugin ca
|
||||
listen for. We will go into the details of the different signals in the following
|
||||
pages. We suggest that you put your signal receivers into a ``signals`` submodule
|
||||
of your plugin. You should extend your ``AppConfig`` (see above) by the following
|
||||
method to make your receivers available::
|
||||
method to make your receivers available:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class PaypalApp(AppConfig):
|
||||
…
|
||||
@@ -127,7 +133,9 @@ method to make your receivers available::
|
||||
from . import signals # NOQA
|
||||
|
||||
You can optionally specify code that is executed when your plugin is activated for an event
|
||||
in the ``installed`` method::
|
||||
in the ``installed`` method:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class PaypalApp(AppConfig):
|
||||
…
|
||||
|
||||
@@ -74,7 +74,7 @@ looks like this:
|
||||
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'invoice-addresses.json', 'application/json', json.dumps({
|
||||
ia.order.code: InvoiceAdddressSerializer(ia).data
|
||||
ia.order.code: InvoiceAddressSerializer(ia).data
|
||||
for ia in InvoiceAddress.objects.filter(order__event=self.event)
|
||||
}, indent=4)
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ Output registration
|
||||
The ticket output API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available ticket outputs. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.ticketoutput.BaseTicketOutput``
|
||||
that we'll provide in this plugin::
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ Implementing a task
|
||||
-------------------
|
||||
|
||||
A common pattern for implementing asynchronous tasks can be seen a lot in ``pretix.base.services``
|
||||
and looks like this::
|
||||
and looks like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.celery_app import app
|
||||
|
||||
@@ -34,13 +36,15 @@ If your user needs to wait for the response of the asynchronous task, there are
|
||||
that will probably move to ``pretix.base`` at some point. They consist of the view mixin ``AsyncAction`` that allows
|
||||
you to easily write a view that kicks off and waits for an asynchronous task. ``AsyncAction`` will determine whether
|
||||
to run the task asynchronously or not and will do some magic to look nice for users with and without JavaScript support.
|
||||
A usage example taken directly from the code is::
|
||||
A usage example taken directly from the code is:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
|
||||
"""
|
||||
A view that executes a task asynchronously. A POST request will kick off the
|
||||
task into the background or run it in the foreground if celery is not installed.
|
||||
In the former case, subsequent GET calls can be used to determinine the current
|
||||
In the former case, subsequent GET calls can be used to determine the current
|
||||
status of the task.
|
||||
"""
|
||||
|
||||
@@ -79,7 +83,9 @@ A usage example taken directly from the code is::
|
||||
return super().get_error_message(exception)
|
||||
|
||||
On the client side, this can be used by simply adding a ``data-asynctask`` attribute to an HTML form. This will enable
|
||||
AJAX sending of the form and display a loading indicator::
|
||||
AJAX sending of the form and display a loading indicator:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<form method="post" data-asynctask
|
||||
action="{% eventurl request.event "presale:event.order.cancel.do" … %}">
|
||||
|
||||
@@ -27,7 +27,9 @@ numbers and dates, ``LazyDate`` and ``LazyNumber``. There also is a ``LazyLocale
|
||||
exceptions with gettext-localized exception messages.
|
||||
|
||||
Last, but definitely not least, we have the ``language`` context manager (``pretix.base.i18n.language``) that allows
|
||||
you to execute a piece of code with a different locale::
|
||||
you to execute a piece of code with a different locale:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with language('de'):
|
||||
render_mail_template()
|
||||
|
||||
@@ -16,7 +16,9 @@ We recommend all relevant models to inherit from ``LoggedModel`` as it simplifie
|
||||
.. autoclass:: pretix.base.models.LoggedModel
|
||||
:members: log_action, all_logentries
|
||||
|
||||
To actually log an action, you can just call the ``log_action`` method on your object::
|
||||
To actually log an action, you can just call the ``log_action`` method on your object:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, data={})
|
||||
|
||||
@@ -29,7 +31,9 @@ Logging form actions
|
||||
""""""""""""""""""""
|
||||
|
||||
A very common use case is to log the changes to a model that have been done in a ``ModelForm``. In this case,
|
||||
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this::
|
||||
we generally use a custom ``form_valid`` method on our ``FormView`` that looks like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
@@ -40,7 +44,9 @@ we generally use a custom ``form_valid`` method on our ``FormView`` that looks l
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
It gets a little bit more complicated if your form allows file uploads::
|
||||
It gets a little bit more complicated if your form allows file uploads:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
@@ -67,7 +73,9 @@ following ready-to-include template::
|
||||
|
||||
We now need a way to translate the action codes like ``pretix.event.changed`` into human-readable
|
||||
strings. The :py:attr:`pretix.base.signals.logentry_display` signals allows you to do so. A simple
|
||||
implementation could look like::
|
||||
implementation could look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from pretix.base.signals import logentry_display
|
||||
@@ -88,7 +96,9 @@ Sending notifications
|
||||
|
||||
If you think that the logged information might be important or urgent enough to send out a notification to interested
|
||||
organizers. In this case, you should listen for the :py:attr:`pretix.base.signals.register_notification_types` signal
|
||||
to register a notification type::
|
||||
to register a notification type:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@receiver(register_notification_types)
|
||||
def register_my_notification_types(sender, **kwargs):
|
||||
@@ -103,7 +113,9 @@ You should subclass the base ``NotificationType`` class and implement all its me
|
||||
.. autoclass:: pretix.base.notifications.NotificationType
|
||||
:members: action_type, verbose_name, required_permission, build_notification
|
||||
|
||||
A simple implementation could look like this::
|
||||
A simple implementation could look like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyNotificationType(NotificationType):
|
||||
required_permission = "can_view_orders"
|
||||
@@ -143,7 +155,9 @@ Logging technical information
|
||||
-----------------------------
|
||||
|
||||
If you just want to log technical information to a log file on disk that does not need to be parsed
|
||||
and displayed later, you can just use Python's ``logging`` module::
|
||||
and displayed later, you can just use Python's ``logging`` module:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import logging
|
||||
|
||||
@@ -151,7 +165,9 @@ and displayed later, you can just use Python's ``logging`` module::
|
||||
|
||||
logger.info('Startup complete.')
|
||||
|
||||
This is also very useful to provide debugging information when an exception occurs::
|
||||
This is also very useful to provide debugging information when an exception occurs:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
foo()
|
||||
|
||||
@@ -15,7 +15,9 @@ Requiring permissions for a view
|
||||
--------------------------------
|
||||
|
||||
pretix provides a number of useful mixins and decorators that allow you to specify that a user needs a certain
|
||||
permission level to access a view::
|
||||
permission level to access a view:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.control.permissions import (
|
||||
OrganizerPermissionRequiredMixin, organizer_permission_required
|
||||
@@ -44,7 +46,9 @@ permission level to access a view::
|
||||
# Only users with *any* permission on this organizer can access this
|
||||
|
||||
|
||||
Of course, the same is available on event level::
|
||||
Of course, the same is available on event level:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.control.permissions import (
|
||||
EventPermissionRequiredMixin, event_permission_required
|
||||
@@ -73,7 +77,9 @@ Of course, the same is available on event level::
|
||||
# Only users with *any* permission on this event can access this
|
||||
|
||||
You can also require that this view is only accessible by system administrators with an active "admin session"
|
||||
(see below for what this means)::
|
||||
(see below for what this means):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, administrator_permission_required
|
||||
@@ -89,7 +95,9 @@ You can also require that this view is only accessible by system administrators
|
||||
# ...
|
||||
|
||||
In rare cases it might also be useful to expose a feature only to people who have a staff account but do not
|
||||
necessarily have an active admin session::
|
||||
necessarily have an active admin session:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.control.permissions import (
|
||||
StaffMemberRequiredMixin, staff_member_required
|
||||
|
||||
@@ -39,7 +39,9 @@ subclass that also adds support for internationalized fields:
|
||||
|
||||
.. autoclass:: pretix.base.forms.SettingsForm
|
||||
|
||||
You can simply use it like this::
|
||||
You can simply use it like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class EventSettingsForm(SettingsForm):
|
||||
show_date_to = forms.BooleanField(
|
||||
@@ -56,7 +58,9 @@ You can simply use it like this::
|
||||
Defaults in plugins
|
||||
-------------------
|
||||
|
||||
Plugins can add custom hardcoded defaults in the following way::
|
||||
Plugins can add custom hardcoded defaults in the following way:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pretix.base.settings import settings_hierarkey
|
||||
|
||||
|
||||
@@ -64,20 +64,35 @@ is valid in every text):
|
||||
Placeholder Description
|
||||
============================== ===============================================================================
|
||||
event The event name
|
||||
event_slug The event's short form
|
||||
code In case of the waiting list, the voucher code to redeem
|
||||
currency The currency used for the event (three-letter code)
|
||||
total The order's total value
|
||||
total_with_currency The order's total value with a localized currency sign
|
||||
currency The currency used for the event (three-letter code)
|
||||
refund_amount (For cancellation emails) The amount of money that will be refunded, including
|
||||
the currency
|
||||
payment_info Information text specific to the payment method (e.g. banking details)
|
||||
url An URL pointing to the download/status page of the order
|
||||
invoice_name The name field of the invoice address
|
||||
url_info_change An URL pointing to the page of the order that can be used to change ticket
|
||||
information
|
||||
url_products_change An URL pointing to the page of the order that can be used to change the products
|
||||
in the order
|
||||
url_cancel An URL pointing to the page of the order that can be used to cancel the order
|
||||
name, name_* Any name that can be used to address the recipient (e.g. name from invoice address,
|
||||
name from first ticket, …)
|
||||
invoice_name, invoice_name_* The name field of the invoice address
|
||||
invoice_company The company field of the invoice address
|
||||
attendee_name, attendee_name_* The name of the attendee represented by the ticket
|
||||
expire_date The order's expiration date
|
||||
comment When rejecting an order, this will contain the reason for the rejection
|
||||
date The same as ``expire_date``, but in a different e-mail (for backwards
|
||||
compatibility)
|
||||
orders A list of orders including links to their status pages, specific to the "resend
|
||||
link (requested by user)" e-mail
|
||||
code In case of the waiting list, the voucher code to redeem
|
||||
hours In case of the waiting list, the number of hours the voucher code is valid
|
||||
product In case of the waiting list, the product that has become available
|
||||
voucher_list When sending out vouchers in bulk, this will be replaced with the list of
|
||||
vouchers
|
||||
============================== ===============================================================================
|
||||
|
||||
The different e-mails are explained in the following:
|
||||
|
||||
@@ -304,8 +304,92 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
|
||||
made through this widget will be counted towards this campaign.
|
||||
|
||||
* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
|
||||
require you to dynamically load the widget, like this::
|
||||
* If you use the tracking plugin, you can enable cross-domain tracking. To do so, you need to initialize the
|
||||
pretix-widget manually. Use the html code to embed the widget and add one the following code snippets. Make sure to
|
||||
replace all occurrences of <MEASUREMENT_ID> with your Google Analytics MEASUREMENT_ID (UA-XXXXXXX-X or G-XXXXXXXX)
|
||||
|
||||
Please also make sure to add the embedding website to your `Referral exclusions
|
||||
<https://support.google.com/analytics/answer/2795830>`_ in your Google Analytics settings.
|
||||
|
||||
If you use Google Analytics 4 (GA4 – G-XXXXXXXX)::
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
var clientId;
|
||||
var sessionId;
|
||||
var loadingTimeout;
|
||||
function build() {
|
||||
// use loadingTimeout to make sure build() is only called once
|
||||
if (!loadingTimeout) return;
|
||||
window.clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
if (clientId) window.PretixWidget.widget_data["tracking-ga-id"] = clientId;
|
||||
if (sessionId) window.PretixWidget.widget_data["tracking-ga-sessid"] = sessionId;
|
||||
window.PretixWidget.buildWidgets();
|
||||
};
|
||||
// make sure to build pretix-widgets if gtag fails to load either client_id or session_id
|
||||
loadingTimeout = window.setTimeout(build, 2000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
clientId = id;
|
||||
if (sessionId !== undefined) build();
|
||||
});
|
||||
gtag('get', '<MEASUREMENT_ID>', 'session_id', function(id) {
|
||||
sessionId = id;
|
||||
if (clientId !== undefined) build();
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use Universal Analytics with ``gtag.js`` (UA-XXXXXXX-X)::
|
||||
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<MEASUREMENT_ID>"></script>
|
||||
<script type="text/javascript">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '<MEASUREMENT_ID>');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if (!window['google_tag_manager']) {
|
||||
window.PretixWidget.buildWidgets();
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure to build pretix-widgets if gtag fails to load client_id
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
|
||||
gtag('get', '<MEASUREMENT_ID>', 'client_id', function(id) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = id;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
If you use ```analytics.js` (Universal Analytics)::
|
||||
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
@@ -313,27 +397,33 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-XXXXXX-1', 'auto');
|
||||
ga('create', '<MEASUREMENT_ID>', 'auto');
|
||||
ga('send', 'pageview');
|
||||
|
||||
window.pretixWidgetCallback = function () {
|
||||
window.PretixWidget.build_widgets = false;
|
||||
window.addEventListener('load', function() { // Wait for GA to be loaded
|
||||
if(window.ga && ga.create) {
|
||||
ga(function(tracker) {
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets()
|
||||
});
|
||||
} else { // Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
if (!window['ga'] || !ga.create) {
|
||||
// Tracking is probably blocked
|
||||
window.PretixWidget.buildWidgets()
|
||||
return;
|
||||
}
|
||||
|
||||
var loadingTimeout = window.setTimeout(function() {
|
||||
loadingTimeout = null;
|
||||
window.PretixWidget.buildWidgets();
|
||||
}, 1000);
|
||||
ga(function(tracker) {
|
||||
if (loadingTimeout) {
|
||||
window.clearTimeout(loadingTimeout);
|
||||
window.PretixWidget.widget_data["tracking-ga-id"] = tracker.get('clientId');
|
||||
window.PretixWidget.buildWidgets();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
|
||||
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
|
||||
|
||||
|
||||
.. versionchanged:: 2.3
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.14.1"
|
||||
__version__ = "3.15.0"
|
||||
|
||||
@@ -42,6 +42,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +69,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
@@ -113,6 +115,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'plugins:pretix_seating:event.event.subevent'),
|
||||
('GET', 'plugins:pretix_seating:event.plan'),
|
||||
('GET', 'plugins:pretix_seating:selection.simple'),
|
||||
('POST', 'api-v1:upload'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,38 @@ class EventCRUDPermission(EventPermission):
|
||||
class ProfilePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated:
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||
assert_session_valid(request)
|
||||
except SessionInvalid:
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AnyAuthenticatedClientPermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
# If this logic is updated, make sure to also update the logic in pretix/control/middleware.py
|
||||
assert_session_valid(request)
|
||||
except SessionInvalid:
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files import File
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -100,8 +101,15 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options')
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = cp.answers.create(**answ_data, answer='')
|
||||
answ.file.save(an.name, an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = cp.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
return cp
|
||||
|
||||
def validate_cart_id(self, cid):
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from pytz import common_timezones
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.models import Event, TaxRule
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import SubEventItem, SubEventItemVariation
|
||||
from pretix.base.services.seating import (
|
||||
SeatProtected, generate_seats, validate_plan_change,
|
||||
)
|
||||
from pretix.base.settings import DEFAULTS, validate_event_settings
|
||||
from pretix.base.settings import validate_event_settings
|
||||
from pretix.base.signals import api_event_settings_fields
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetaDataField(Field):
|
||||
|
||||
@@ -558,7 +562,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||
|
||||
|
||||
class EventSettingsSerializer(serializers.Serializer):
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
default_fields = [
|
||||
'imprint_url',
|
||||
'checkout_email_helptext',
|
||||
@@ -654,6 +658,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
@@ -663,6 +668,7 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
'cancel_allow_user_paid_adjust_fees',
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_adjust_fees_step',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
@@ -674,45 +680,21 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'logo_image',
|
||||
'logo_image_large',
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
self.fields[fname] = f
|
||||
|
||||
for recv, resp in api_event_settings_fields.send(sender=self.event):
|
||||
for fname, field in resp.items():
|
||||
field.required = False
|
||||
self.fields[fname] = field
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
settings_dict = self.instance.freeze()
|
||||
@@ -720,6 +702,14 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
validate_event_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.event.organizer.slug, self.event.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||
)
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
|
||||
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
default_fields = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
@@ -27,3 +28,50 @@ class ListMultipleChoiceField(serializers.MultipleChoiceField):
|
||||
]
|
||||
|
||||
return remove_duplicates_from_list(representation_data)
|
||||
|
||||
|
||||
class UploadedFileField(serializers.Field):
|
||||
default_error_messages = {
|
||||
'required': 'No file was submitted.',
|
||||
'not_found': 'The submitted file ID was not found.',
|
||||
'invalid_type': 'The submitted file has a file type that is not allowed in this field.',
|
||||
'size': 'The submitted file is too large to be used in this field.',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.allowed_types = kwargs.pop('allowed_types', None)
|
||||
self.max_size = kwargs.pop('max_size', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
from pretix.base.models import CachedFile
|
||||
|
||||
request = self.context.get('request', None)
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
self.fail('not_found')
|
||||
except CachedFile.DoesNotExist:
|
||||
self.fail('not_found')
|
||||
|
||||
if self.allowed_types and cf.type not in self.allowed_types:
|
||||
self.fail('invalid_type')
|
||||
if self.max_size and cf.file.size > self.max_size:
|
||||
self.fail('size')
|
||||
|
||||
return cf.file
|
||||
|
||||
def to_representation(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = value.url
|
||||
except AttributeError:
|
||||
return None
|
||||
request = self.context['request']
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||
@@ -113,6 +114,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
), max_size=10 * 1024 * 1024)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -123,7 +127,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
read_only_fields = ('has_variations',)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections import Counter, defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
from django.core.files import File
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -17,8 +18,9 @@ from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
|
||||
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
||||
SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
|
||||
@@ -94,12 +96,9 @@ class AnswerQuestionIdentifierField(serializers.Field):
|
||||
|
||||
class AnswerQuestionOptionsIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
|
||||
|
||||
class AnswerQuestionOptionsField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.pk for o in instance.options.all()]
|
||||
if isinstance(instance, WrappedModel) or instance.pk:
|
||||
return [o.identifier for o in instance.options.all()]
|
||||
return []
|
||||
|
||||
|
||||
class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
@@ -112,12 +111,91 @@ class InlineSeatSerializer(I18nAwareModelSerializer):
|
||||
class AnswerSerializer(I18nAwareModelSerializer):
|
||||
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
|
||||
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
|
||||
options = AnswerQuestionOptionsField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def _handle_file_upload(self, data):
|
||||
try:
|
||||
ao = self.context["request"].user or self.context["request"].auth
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(ao))}-{ao.pk}',
|
||||
file__isnull=False,
|
||||
pk=data['answer'][len("file:"):],
|
||||
)
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
data['options'] = []
|
||||
data['answer'] = cf.file
|
||||
return data
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
return self._handle_file_upload(data)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
for o in data.get('options'):
|
||||
if o.question_id != data.get('question').pk:
|
||||
raise ValidationError(
|
||||
'The specified option does not belong to this question.'
|
||||
)
|
||||
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||
data['answer'] = 'True'
|
||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
max_digits=50,
|
||||
decimal_places=25
|
||||
).to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATE:
|
||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_TIME:
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
return data
|
||||
|
||||
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
@@ -205,13 +283,14 @@ class PdfDataSerializer(serializers.Field):
|
||||
|
||||
|
||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
checkins = CheckinSerializer(many=True)
|
||||
checkins = CheckinSerializer(many=True, read_only=True)
|
||||
answers = AnswerSerializer(many=True)
|
||||
downloads = PositionDownloadsField(source='*')
|
||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
|
||||
pdf_data = PdfDataSerializer(source='*')
|
||||
pdf_data = PdfDataSerializer(source='*', read_only=True)
|
||||
seat = InlineSeatSerializer(read_only=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
@@ -219,12 +298,99 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled')
|
||||
read_only_fields = (
|
||||
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
|
||||
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
|
||||
'seat', 'canceled'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'request' in self.context and not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
|
||||
self.fields.pop('pdf_data')
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
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
|
||||
|
||||
if data.get('country'):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country').code):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
|
||||
if data.get('state'):
|
||||
cc = str(data.get('country') or self.instance.country or '')
|
||||
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
raise ValidationError(
|
||||
{'state': ['States are not supported in country "{}".'.format(cc)]}
|
||||
)
|
||||
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
|
||||
raise ValidationError(
|
||||
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
|
||||
)
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
|
||||
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
|
||||
update_fields = [
|
||||
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
|
||||
'state', 'attendee_email',
|
||||
]
|
||||
answers_data = validated_data.pop('answers', None)
|
||||
|
||||
name = validated_data.pop('attendee_name', '')
|
||||
if name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
|
||||
for attr, value in validated_data.items():
|
||||
if attr in update_fields:
|
||||
setattr(instance, attr, value)
|
||||
|
||||
instance.save(update_fields=update_fields)
|
||||
|
||||
if answers_data is not None:
|
||||
qs_seen = set()
|
||||
answercache = {
|
||||
a.question_id: a for a in instance.answers.all()
|
||||
}
|
||||
for answ_data in answers_data:
|
||||
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):
|
||||
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
else:
|
||||
for attr, value in answ_data.items():
|
||||
setattr(a, attr, value)
|
||||
a.save()
|
||||
else:
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
a = instance.answers.create(**answ_data, answer='')
|
||||
a.file.save(an.name, an, save=False)
|
||||
a.answer = 'file://' + a.file.name
|
||||
a.save()
|
||||
else:
|
||||
a = instance.answers.create(**answ_data)
|
||||
a.options.set(options)
|
||||
qs_seen.add(a.question_id)
|
||||
for qid, a in answercache.items():
|
||||
if qid not in qs_seen:
|
||||
a.delete()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class RequireAttentionField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
@@ -336,7 +502,7 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
|
||||
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'comment', 'provider')
|
||||
|
||||
|
||||
class OrderURLField(serializers.URLField):
|
||||
@@ -425,7 +591,17 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class AnswerQuestionOptionsField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
return [o.pk for o in instance.options.all()]
|
||||
|
||||
|
||||
class SimulatedAnswerSerializer(AnswerSerializer):
|
||||
options = AnswerQuestionOptionsField(read_only=True, source='*')
|
||||
|
||||
|
||||
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||
answers = SimulatedAnswerSerializer(many=True)
|
||||
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
||||
|
||||
|
||||
@@ -452,62 +628,8 @@ class PriceCalcSerializer(serializers.Serializer):
|
||||
del self.fields['subevent']
|
||||
|
||||
|
||||
class AnswerCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = QuestionAnswer
|
||||
fields = ('question', 'answer', 'options')
|
||||
|
||||
def validate_question(self, q):
|
||||
if q.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
'The specified question does not belong to this event.'
|
||||
)
|
||||
return q
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('question').type == Question.TYPE_FILE:
|
||||
raise ValidationError(
|
||||
'File uploads are currently not supported via the API.'
|
||||
)
|
||||
elif data.get('question').type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||
if not data.get('options'):
|
||||
raise ValidationError(
|
||||
'You need to specify options if the question is of a choice type.'
|
||||
)
|
||||
if data.get('question').type == Question.TYPE_CHOICE and len(data.get('options')) > 1:
|
||||
raise ValidationError(
|
||||
'You can specify at most one option for this question.'
|
||||
)
|
||||
data['answer'] = ", ".join([str(o) for o in data.get('options')])
|
||||
|
||||
else:
|
||||
if data.get('options'):
|
||||
raise ValidationError(
|
||||
'You should not specify options if the question is not of a choice type.'
|
||||
)
|
||||
|
||||
if data.get('question').type == Question.TYPE_BOOLEAN:
|
||||
if data.get('answer') in ['true', 'True', '1', 'TRUE']:
|
||||
data['answer'] = 'True'
|
||||
elif data.get('answer') in ['false', 'False', '0', 'FALSE']:
|
||||
data['answer'] = 'False'
|
||||
else:
|
||||
raise ValidationError(
|
||||
'Please specify "true" or "false" for boolean questions.'
|
||||
)
|
||||
elif data.get('question').type == Question.TYPE_NUMBER:
|
||||
serializers.DecimalField(
|
||||
max_digits=50,
|
||||
decimal_places=25
|
||||
).to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATE:
|
||||
data['answer'] = serializers.DateField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_TIME:
|
||||
data['answer'] = serializers.TimeField().to_internal_value(data.get('answer'))
|
||||
elif data.get('question').type == Question.TYPE_DATETIME:
|
||||
data['answer'] = serializers.DateTimeField().to_internal_value(data.get('answer'))
|
||||
return data
|
||||
class AnswerCreateSerializer(AnswerSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class OrderFeeCreateSerializer(I18nAwareModelSerializer):
|
||||
@@ -1044,8 +1166,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos.save()
|
||||
for answ_data in answers_data:
|
||||
options = answ_data.pop('options', [])
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
if isinstance(answ_data['answer'], File):
|
||||
an = answ_data.pop('answer')
|
||||
answ = pos.answers.create(**answ_data, answer='')
|
||||
answ.file.save(an.name, an, save=False)
|
||||
answ.answer = 'file://' + answ.file.name
|
||||
answ.save()
|
||||
else:
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
pos_map[pos.positionid] = pos
|
||||
|
||||
if not simulate:
|
||||
@@ -1194,7 +1324,7 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderRefund
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
|
||||
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info', 'comment')
|
||||
|
||||
def create(self, validated_data):
|
||||
pid = validated_data.pop('payment', None)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import CompatibleJSONField
|
||||
from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
@@ -16,9 +18,11 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import DEFAULTS, validate_organizer_settings
|
||||
from pretix.base.settings import validate_organizer_settings
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrganizerSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
@@ -207,7 +211,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
default_fields = [
|
||||
'organizer_info_text',
|
||||
'event_list_type',
|
||||
@@ -225,40 +229,13 @@ class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
'theme_color_danger',
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font'
|
||||
'primary_font',
|
||||
'organizer_logo_image'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
self.fields[fname] = f
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -266,3 +243,11 @@ class OrganizerSettingsSerializer(serializers.Serializer):
|
||||
settings_dict.update(data)
|
||||
validate_organizer_settings(self.organizer, settings_dict)
|
||||
return data
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
fname = '%s/%s.%s.%s' % (
|
||||
self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1]
|
||||
)
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
77
src/pretix/api/serializers/settings.py
Normal file
77
src/pretix/api/serializers/settings.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models.fields.files import FieldFile
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.base.settings import DEFAULTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsSerializer(serializers.Serializer):
|
||||
default_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.changed_data = []
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.default_fields:
|
||||
kwargs = DEFAULTS[fname].get('serializer_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
kwargs.setdefault('allow_null', True)
|
||||
form_kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(form_kwargs):
|
||||
form_kwargs = form_kwargs()
|
||||
if 'serializer_class' not in DEFAULTS[fname]:
|
||||
raise ValidationError('{} has no serializer class'.format(fname))
|
||||
f = DEFAULTS[fname]['serializer_class'](
|
||||
**kwargs
|
||||
)
|
||||
f._label = form_kwargs.get('label', fname)
|
||||
f._help_text = form_kwargs.get('help_text')
|
||||
f.parent = self
|
||||
self.fields[fname] = f
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if isinstance(value, FieldFile):
|
||||
# Delete old file
|
||||
fname = instance.get(attr, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError: # pragma: no cover
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
|
||||
# Create new file
|
||||
newname = default_storage.save(self.get_new_filename(value.name), value)
|
||||
instance.set(attr, File(file=value, name=newname))
|
||||
self.changed_data.append(attr)
|
||||
elif isinstance(self.fields[attr], UploadedFileField):
|
||||
if value is None:
|
||||
fname = instance.get(attr, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError: # pragma: no cover
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
instance.delete(attr)
|
||||
else:
|
||||
# file is unchanged
|
||||
continue
|
||||
elif value is None:
|
||||
instance.delete(attr)
|
||||
self.changed_data.append(attr)
|
||||
elif instance.get(attr, as_type=type(value)) != value:
|
||||
instance.set(attr, value)
|
||||
self.changed_data.append(attr)
|
||||
return instance
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
raise NotImplementedError()
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
||||
version, voucher, waitinglist, webhooks,
|
||||
checkin, device, event, exporters, item, oauth, order, organizer, upload,
|
||||
user, version, voucher, waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -95,6 +95,7 @@ urlpatterns = [
|
||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
||||
url(r"^upload$", upload.UploadView.as_view(), name="upload"),
|
||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
url(r"^version$", version.VersionView.as_view(), name="version"),
|
||||
]
|
||||
|
||||
@@ -22,7 +22,7 @@ from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
@@ -302,7 +302,10 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in aws:
|
||||
try:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
if q.type == Question.TYPE_FILE:
|
||||
given_answers[q] = self._handle_file_upload(aws[str(q.pk)])
|
||||
else:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
@@ -352,3 +355,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def _handle_file_upload(self, data):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > 10 * 1024 * 1024:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
@@ -365,9 +365,13 @@ class EventSettingsView(views.APIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if isinstance(request.auth, Device):
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
elif 'can_change_event_settings' in request.eventpermset:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
if 'explain' in request.GET:
|
||||
@@ -382,7 +386,7 @@ class EventSettingsView(views.APIView):
|
||||
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||
event=request.event)
|
||||
event=request.event, context={'request': request})
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
s.save()
|
||||
@@ -392,6 +396,9 @@ class EventSettingsView(views.APIView):
|
||||
}
|
||||
)
|
||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||
regenerate_css.apply_async(args=(request.organizer.pk,))
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
regenerate_css.apply_async(args=(request.event.pk,))
|
||||
s = EventSettingsSerializer(
|
||||
instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
@@ -763,7 +763,7 @@ with scopes_disabled():
|
||||
}
|
||||
|
||||
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
@@ -783,6 +783,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
},
|
||||
}
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.query_params.get('include_canceled_positions', 'false') == 'true':
|
||||
qs = OrderPosition.all
|
||||
@@ -951,6 +956,44 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
except Quota.QuotaExceededException as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
partial = kwargs.get('partial', False)
|
||||
if not partial:
|
||||
return Response(
|
||||
{"detail": "Method \"PUT\" not allowed."},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
with transaction.atomic():
|
||||
old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
|
||||
serializer.save()
|
||||
new_data = serializer.data
|
||||
|
||||
if old_data != new_data:
|
||||
log_data = self.request.data
|
||||
if 'answers' in log_data:
|
||||
for a in new_data['answers']:
|
||||
log_data[f'question_{a["question"]}'] = a["answer"]
|
||||
log_data.pop('answers', None)
|
||||
serializer.instance.order.log_action(
|
||||
'pretix.event.order.modified',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'data': [
|
||||
dict(
|
||||
position=serializer.instance.pk,
|
||||
**log_data
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
|
||||
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
|
||||
|
||||
|
||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPaymentSerializer
|
||||
|
||||
@@ -425,7 +425,9 @@ class OrganizerSettingsView(views.APIView):
|
||||
permission = 'can_change_organizer_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request
|
||||
})
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
@@ -439,7 +441,9 @@ class OrganizerSettingsView(views.APIView):
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = OrganizerSettingsSerializer(
|
||||
instance=request.organizer.settings, data=request.data, partial=True,
|
||||
organizer=request.organizer
|
||||
organizer=request.organizer, context={
|
||||
'request': request
|
||||
}
|
||||
)
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -451,5 +455,7 @@ class OrganizerSettingsView(views.APIView):
|
||||
)
|
||||
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
|
||||
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer)
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
55
src/pretix/api/views/upload.py
Normal file
55
src/pretix/api/views/upload.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import datetime
|
||||
|
||||
from django.utils.timezone import now
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import FileUploadParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
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
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'image/gif': {'.gif'},
|
||||
'image/jpeg': {'.jpg', '.jpeg'},
|
||||
'image/png': {'.png'},
|
||||
'application/pdf': {'.pdf'},
|
||||
}
|
||||
|
||||
|
||||
class UploadView(APIView):
|
||||
authentication_classes = (
|
||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||
)
|
||||
parser_classes = [FileUploadParser]
|
||||
permission_classes = [AnyAuthenticatedClientPermission]
|
||||
|
||||
def post(self, request):
|
||||
if 'file' not in request.data:
|
||||
raise ValidationError('No file has been submitted.')
|
||||
file_obj = request.data['file']
|
||||
content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…"
|
||||
if content_type not in ALLOWED_TYPES:
|
||||
raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type))
|
||||
if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]):
|
||||
raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format(
|
||||
name=file_obj.name,
|
||||
type=content_type
|
||||
))
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + datetime.timedelta(days=1),
|
||||
date=now(),
|
||||
web_download=False,
|
||||
filename=file_obj.name,
|
||||
type=content_type,
|
||||
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
|
||||
)
|
||||
cf.file.save(file_obj.name, file_obj)
|
||||
cf.save()
|
||||
return Response({
|
||||
'id': f'file:{cf.pk}'
|
||||
}, status=201)
|
||||
@@ -6,6 +6,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.api.auth.device import DeviceTokenAuthentication
|
||||
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
|
||||
from pretix.api.auth.token import TeamTokenAuthentication
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ class VersionView(APIView):
|
||||
authentication_classes = (
|
||||
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
|
||||
)
|
||||
permission_classes = [AnyAuthenticatedClientPermission]
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
|
||||
@@ -427,28 +427,30 @@ def base_placeholders(sender, **kwargs):
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
'secret': order.secret,
|
||||
'hash': order.email_confirm_hash(),
|
||||
}),
|
||||
)
|
||||
for order in orders
|
||||
), lambda event: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
'{}-{}'.format(event.slug.upper(), order['code']),
|
||||
build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
build_absolute_uri(event, 'presale:event.order.open', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug,
|
||||
'order': order['code'],
|
||||
'secret': order['secret']
|
||||
'secret': order['secret'],
|
||||
'hash': order['hash'],
|
||||
}),
|
||||
)
|
||||
for order in [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
@@ -59,6 +59,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
('group_multiple_choice',
|
||||
forms.BooleanField(
|
||||
label=_('Show multiple choice answers grouped in one column'),
|
||||
initial=False,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -449,9 +455,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
options[q.pk] = []
|
||||
for o in q.options.all():
|
||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||
options[q.pk].append(o)
|
||||
if form_data['group_multiple_choice']:
|
||||
for o in q.options.all():
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.question))
|
||||
else:
|
||||
for o in q.options.all():
|
||||
headers.append(str(q.question) + ' – ' + str(o.answer))
|
||||
options[q.pk].append(o)
|
||||
else:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
@@ -551,8 +562,11 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
if form_data['group_multiple_choice']:
|
||||
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
|
||||
else:
|
||||
for o in options[q.pk]:
|
||||
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
|
||||
else:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
|
||||
@@ -638,7 +652,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment')
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -660,7 +674,8 @@ class PaymentListExporter(ListExporter):
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
obj.amount * (-1 if isinstance(obj, OrderRefund) else 1),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
provider_names.get(obj.provider, obj.provider),
|
||||
obj.comment if isinstance(obj, OrderRefund) else "",
|
||||
]
|
||||
yield row
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ import pycountry
|
||||
import pytz
|
||||
import vat_moss.errors
|
||||
import vat_moss.id
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -24,9 +26,7 @@ from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import (
|
||||
PhoneNumberPrefixWidget, PhonePrefixSelect,
|
||||
)
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from phonenumbers import NumberParseException, national_significant_number
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
|
||||
@@ -205,10 +205,39 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
return value
|
||||
|
||||
|
||||
class WrappedPhonePrefixSelect(PhonePrefixSelect):
|
||||
def __init__(self, *args, **kwargs):
|
||||
with language(get_babel_locale()):
|
||||
super().__init__(*args, **kwargs)
|
||||
class WrappedPhonePrefixSelect(Select):
|
||||
initial = None
|
||||
|
||||
def __init__(self, initial=None):
|
||||
choices = [("", "---------")]
|
||||
language = get_babel_locale() # changed from default implementation that used the django locale
|
||||
locale = Locale(translation.to_locale(language))
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
prefix = "+%d" % prefix
|
||||
if initial and initial in values:
|
||||
self.initial = prefix
|
||||
for country_code in values:
|
||||
country_name = locale.territories.get(country_code)
|
||||
if country_name:
|
||||
choices.append((prefix, "{} {}".format(country_name, prefix)))
|
||||
super().__init__(choices=sorted(choices, key=lambda item: item[1]))
|
||||
|
||||
def render(self, name, value, *args, **kwargs):
|
||||
return super().render(name, value or self.initial, *args, **kwargs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
if value and self.choices[1][0] != value:
|
||||
matching_choices = len([1 for p, c in self.choices if p == value])
|
||||
if matching_choices > 1:
|
||||
# Some countries share a phone prefix, for example +1 is used all over the Americas.
|
||||
# This causes a UX problem: If the default value or the existing data is +12125552368,
|
||||
# the widget will just show the first <option> entry with value="+1" as selected,
|
||||
# which alphabetically is America Samoa, although most numbers statistically are from
|
||||
# the US. As a workaround, we detect this case and add an aditional choice value with
|
||||
# just <option value="+1">+1</option> without an explicit country.
|
||||
self.choices.insert(1, (value, value))
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
|
||||
|
||||
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
@@ -661,8 +690,9 @@ class BaseQuestionsForm(forms.Form):
|
||||
if not self.all_optional:
|
||||
for q in question_cache.values():
|
||||
answer = d.get('question_%d' % q.pk)
|
||||
if question_is_required(q) and not answer and answer != 0:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
|
||||
field = self['question_%d' % q.pk]
|
||||
if question_is_required(q) and not answer and answer != 0 and not field.errors:
|
||||
raise ValidationError({'question_%d' % q.pk: [_('This field is required.')]})
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@@ -18,10 +18,13 @@ class DatePickerWidget(forms.DateInput):
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
def placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
date_attrs['placeholder'] = lazy(placeholder, str)
|
||||
|
||||
forms.DateInput.__init__(self, date_attrs, date_format)
|
||||
|
||||
@@ -36,10 +39,13 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
def placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
time_attrs['placeholder'] = lazy(placeholder, str)
|
||||
|
||||
forms.TimeInput.__init__(self, time_attrs, time_format)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
@@ -6,6 +8,7 @@ from django.conf import settings
|
||||
from django.db import connection
|
||||
|
||||
from pretix.base.models import Event, Invoice, Order, OrderPosition, Organizer
|
||||
from pretix.celery_app import app
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
@@ -248,6 +251,19 @@ def metric_values():
|
||||
else:
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = estimate_count_fast(m)
|
||||
|
||||
if settings.HAS_CELERY:
|
||||
client = app.broker_connection().channel().client
|
||||
for q in settings.CELERY_TASK_QUEUES:
|
||||
llen = client.llen(q.name)
|
||||
lfirst = client.lindex(q.name, -1)
|
||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
||||
if lfirst:
|
||||
ldata = json.loads(lfirst)
|
||||
dt = time.time() - ldata.get('created', 0)
|
||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
||||
else:
|
||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
|
||||
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
18
src/pretix/base/migrations/0175_orderrefund_comment.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.11 on 2021-01-15 09:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0174_merge_20201222_1031'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderrefund',
|
||||
name='comment',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -10,7 +10,9 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import (
|
||||
MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery, Value
|
||||
from django.template.defaultfilters import date as _date
|
||||
@@ -353,8 +355,11 @@ class Event(EventMixin, LoggedModel):
|
||||
"remembered, but you can also choose to use a random value. "
|
||||
"This will be used in URLs, order codes, invoice numbers, and bank transfer references."),
|
||||
validators=[
|
||||
MinLengthValidator(
|
||||
limit_value=2,
|
||||
),
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes."),
|
||||
),
|
||||
EventSlugBanlistValidator()
|
||||
@@ -393,10 +398,18 @@ class Event(EventMixin, LoggedModel):
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-90),
|
||||
MaxValueValidator(90),
|
||||
]
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-180),
|
||||
MaxValueValidator(180),
|
||||
]
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=False, blank=True,
|
||||
@@ -1121,10 +1134,18 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-90),
|
||||
MaxValueValidator(90),
|
||||
]
|
||||
)
|
||||
geo_lon = models.FloatField(
|
||||
verbose_name=_("Longitude"),
|
||||
null=True, blank=True
|
||||
null=True, blank=True,
|
||||
validators=[
|
||||
MinValueValidator(-180),
|
||||
MaxValueValidator(180),
|
||||
]
|
||||
)
|
||||
frontpage_text = I18nTextField(
|
||||
null=True, blank=True,
|
||||
|
||||
@@ -1026,7 +1026,7 @@ class Question(LoggedModel):
|
||||
(TYPE_PHONENUMBER, _("Phone number")),
|
||||
)
|
||||
UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER]
|
||||
ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER]
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -1069,6 +1069,7 @@ class Question(LoggedModel):
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
|
||||
@@ -615,21 +615,26 @@ class Order(LockModel, LoggedModel):
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
def normalize_code(code, is_fallback=False):
|
||||
d = {
|
||||
'2': 'Z',
|
||||
'4': 'A',
|
||||
'5': 'S',
|
||||
'6': 'G',
|
||||
})
|
||||
}
|
||||
if is_fallback:
|
||||
d['8'] = 'B'
|
||||
# 8 has been removed from the character set only in 2021, which means there are a lot of order codes
|
||||
# with an 8 in it around. We only want to replace this when this is used in a fallback.
|
||||
tr = str.maketrans(d)
|
||||
return code.upper().translate(tr)
|
||||
|
||||
def assign_code(self):
|
||||
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
|
||||
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
|
||||
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
|
||||
# handwriting (2/Z, 4/A, 5/S, 6/G, 8/B). This allows for better detection e.g. in incoming wire transfers that
|
||||
# might include OCR'd handwritten text
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ379')
|
||||
iteration = 0
|
||||
length = settings.ENTROPY['order_code']
|
||||
while True:
|
||||
@@ -1219,6 +1224,9 @@ class AbstractPosition(models.Model):
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
if 'attendee_name_parts' in update_fields:
|
||||
update_fields.append('attendee_name_cached')
|
||||
self.attendee_name_cached = self.attendee_name
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
@@ -1708,6 +1716,11 @@ class OrderRefund(models.Model):
|
||||
max_length=255,
|
||||
verbose_name=_("Payment provider")
|
||||
)
|
||||
comment = models.TextField(
|
||||
verbose_name=_("Refund reason"),
|
||||
help_text=_('May be shown to the end user or used e.g. as part of a payment reference.'),
|
||||
null=True, blank=True
|
||||
)
|
||||
info = models.TextField(
|
||||
verbose_name=_("Payment information"),
|
||||
null=True, blank=True
|
||||
@@ -1746,7 +1759,7 @@ class OrderRefund(models.Model):
|
||||
Marks the refund as complete. This does not modify the state of the order.
|
||||
|
||||
:param user: The user who performed the change
|
||||
:param user: The API auth token that performed the change
|
||||
:param auth: The API auth token that performed the change
|
||||
"""
|
||||
self.state = self.REFUND_STATE_DONE
|
||||
self.execution_date = self.execution_date or now()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import string
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -38,8 +38,11 @@ class Organizer(LoggedModel):
|
||||
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
|
||||
"once. This is being used in URLs to refer to your organizer accounts and your events."),
|
||||
validators=[
|
||||
MinLengthValidator(
|
||||
limit_value=2,
|
||||
),
|
||||
RegexValidator(
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]+$",
|
||||
regex="^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$",
|
||||
message=_("The slug may only contain letters, numbers, dots and dashes.")
|
||||
),
|
||||
OrganizerSlugBanlistValidator()
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.db import transaction
|
||||
from django.dispatch import receiver
|
||||
from django.forms import Form
|
||||
@@ -706,12 +706,24 @@ class BasePaymentProvider:
|
||||
It should return HTML code containing information regarding the current payment
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns the verbose name of the payment provider.
|
||||
The default implementation returns an empty string.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return ''
|
||||
|
||||
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
||||
"""
|
||||
Will be called if the *event administrator* performs an action on the payment. Should
|
||||
return a very short version of the payment method. Usually, this should return e.g.
|
||||
a transaction ID or account identifier, but no information on status, dates, etc.
|
||||
|
||||
The default implementation falls back to payment_presa_elrender.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self.payment_presale_render(payment)
|
||||
|
||||
def refund_control_render(self, request: HttpRequest, refund: OrderRefund) -> str:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a refund.
|
||||
@@ -725,6 +737,19 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return ''
|
||||
|
||||
def payment_presale_render(self, payment: OrderPayment) -> str:
|
||||
"""
|
||||
Will be called if the *ticket customer* views the details of a payment. This is
|
||||
currently used e.g. when the customer requests a refund to show which payment
|
||||
method is used for the refund. This should only include very basic information
|
||||
about the payment, such as "VISA card ****9999", and never raw payment information.
|
||||
|
||||
The default implementation returns the public name of the payment provider.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
return self.public_name
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
"""
|
||||
Will be called to check if the provider supports automatic refunding for this
|
||||
@@ -760,6 +785,32 @@ class BasePaymentProvider:
|
||||
"""
|
||||
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
|
||||
|
||||
def new_refund_control_form_render(self, request: HttpRequest, order: Order) -> str:
|
||||
"""
|
||||
Render a form that will be shown to backend users when trying to create a new refund.
|
||||
|
||||
Usually, refunds are created from an existing payment object, e.g. if there is a credit card
|
||||
payment and the credit card provider returns ``True`` from ``payment_refund_supported``, the system
|
||||
will automatically create an ``OrderRefund`` and call ``execute_refund`` on that payment. This method
|
||||
can and should not be used in that situation! Instead, by implementing this method you can add a refund
|
||||
flow for this payment provider that starts without an existing payment. For example, even though an order
|
||||
was paid by credit card, it could easily be refunded by SEPA bank transfer. In that case, the SEPA bank
|
||||
transfer provider would implement this method and return a form that asks for the IBAN.
|
||||
|
||||
This method should return HTML or ``None``. All form fields should have a globally unique name.
|
||||
"""
|
||||
return
|
||||
|
||||
def new_refund_control_form_process(self, request: HttpRequest, amount: Decimal, order: Order) -> OrderRefund:
|
||||
"""
|
||||
Process a backend user's request to initiate a new refund with an amount of ``amount`` for ``order``.
|
||||
|
||||
This method should parse the input provided to the form created and either raise ``ValidationError``
|
||||
or return an ``OrderRefund`` object in ``created`` state that has not yet been saved to the database.
|
||||
The system will then call ``execute_refund`` on that object.
|
||||
"""
|
||||
raise ValidationError('Not implemented')
|
||||
|
||||
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
|
||||
"""
|
||||
When personal data is removed from an event, this method is called to scrub payment-related data
|
||||
@@ -899,7 +950,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
def public_name(self):
|
||||
return str(self.settings.get('public_name', as_type=LazyI18nString))
|
||||
return str(self.settings.get('public_name', as_type=LazyI18nString) or _('Manual payment'))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Subquery
|
||||
from django.utils.translation import gettext
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
@@ -195,7 +196,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
finally:
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
@@ -252,7 +254,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions)
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import dateutil
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.dispatch import receiver
|
||||
@@ -23,7 +24,7 @@ def get_logic_environment(ev):
|
||||
elif t == 'date_from':
|
||||
return ev.date_from
|
||||
elif t == 'date_to':
|
||||
return ev.date_to
|
||||
return ev.date_to or ev.date_from
|
||||
elif t == 'date_admission':
|
||||
return ev.date_admission or ev.date_from
|
||||
|
||||
@@ -125,6 +126,14 @@ def _save_answers(op, answers, given_answers):
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
|
||||
qa.options.add(*a)
|
||||
elif isinstance(a, File):
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
else:
|
||||
qa = op.answers.create(question=q, answer=str(a))
|
||||
qa.file.save(a.name, a, save=False)
|
||||
qa.answer = 'file://' + qa.file.name
|
||||
qa.save()
|
||||
else:
|
||||
if q in answers:
|
||||
qa = answers[q]
|
||||
|
||||
@@ -2034,7 +2034,7 @@ _unset = object()
|
||||
|
||||
|
||||
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None):
|
||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
||||
notify_admin = False
|
||||
error = False
|
||||
if isinstance(order, int):
|
||||
@@ -2059,6 +2059,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
order=order,
|
||||
payment=None,
|
||||
source=source,
|
||||
comment=comment,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
execution_date=now(),
|
||||
amount=can_auto_refund_sum,
|
||||
@@ -2096,6 +2097,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
source=source,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
comment=comment,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
@@ -2125,6 +2127,7 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
source=source,
|
||||
comment=comment,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=refund_amount - can_auto_refund_sum,
|
||||
provider='manual'
|
||||
@@ -2149,13 +2152,14 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
@scopes_disabled()
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False):
|
||||
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None):
|
||||
try:
|
||||
try:
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard)
|
||||
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
|
||||
comment=comment)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -36,7 +36,7 @@ def validate_plan_change(event, subevent, plan):
|
||||
'already sold.'), leftovers[0])
|
||||
|
||||
|
||||
def generate_seats(event, subevent, plan, mapping):
|
||||
def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||
current_seats = {}
|
||||
for s in event.seats.select_related('product').annotate(
|
||||
has_op=Count('orderposition'), has_v=Count('vouchers')
|
||||
@@ -68,7 +68,10 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
update(seat, 'seat_label', ss.seat_label),
|
||||
update(seat, 'x', ss.x),
|
||||
update(seat, 'y', ss.y),
|
||||
])
|
||||
] + (
|
||||
[update(seat, 'blocked', ss.guid in blocked_guids)]
|
||||
if blocked_guids else []
|
||||
))
|
||||
if updated:
|
||||
seat.save()
|
||||
else:
|
||||
@@ -84,6 +87,7 @@ def generate_seats(event, subevent, plan, mapping):
|
||||
seat_label=ss.seat_label,
|
||||
x=ss.x,
|
||||
y=ss.y,
|
||||
blocked=bool(blocked_guids and ss.guid in blocked_guids),
|
||||
product=p,
|
||||
))
|
||||
|
||||
|
||||
@@ -75,16 +75,19 @@ def shred(event: Event, fileid: str, confirm_code: str) -> None:
|
||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||
if indexdata['organizer'] != event.organizer.slug or indexdata['event'] != event.slug:
|
||||
raise ShredError(_("This file is from a different event."))
|
||||
if indexdata['confirm_code'] != confirm_code:
|
||||
raise ShredError(_("The confirm code you entered was incorrect."))
|
||||
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
|
||||
raise ShredError(_("Something happened in your event after the export, please try again."))
|
||||
|
||||
shredders = []
|
||||
for s in indexdata['shredders']:
|
||||
shredder = known_shredders.get(s)
|
||||
if not shredder:
|
||||
continue
|
||||
shredders.append(shredder)
|
||||
if any(shredder.require_download_confirmation for shredder in shredders):
|
||||
if indexdata['confirm_code'] != confirm_code:
|
||||
raise ShredError(_("The confirm code you entered was incorrect."))
|
||||
if event.logentry_set.filter(datetime__gte=parse(indexdata['time'])):
|
||||
raise ShredError(_("Something happened in your event after the export, please try again."))
|
||||
|
||||
for shredder in shredders:
|
||||
shredder.shred_data()
|
||||
|
||||
cf.file.delete(save=False)
|
||||
|
||||
@@ -45,7 +45,8 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
continue
|
||||
if wle.subevent and not wle.subevent.presale_is_running:
|
||||
continue
|
||||
if not wle.item.active or (wle.variation and not wle.variation.active):
|
||||
if not wle.item.is_available():
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
|
||||
quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
|
||||
|
||||
@@ -21,7 +21,9 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers.fields import ListMultipleChoiceField
|
||||
from pretix.api.serializers.fields import (
|
||||
ListMultipleChoiceField, UploadedFileField,
|
||||
)
|
||||
from pretix.api.serializers.i18n import I18nField
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.reldate import (
|
||||
@@ -29,7 +31,7 @@ from pretix.base.reldate import (
|
||||
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||
)
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
|
||||
@@ -1223,6 +1225,21 @@ DEFAULTS = {
|
||||
"e.g. to explain choosing a lower refund will help your organization.")
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_adjust_fees_step': {
|
||||
'default': None,
|
||||
'type': Decimal,
|
||||
'form_class': forms.DecimalField,
|
||||
'serializer_class': serializers.DecimalField,
|
||||
'serializer_kwargs': dict(
|
||||
max_digits=10, decimal_places=2
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
max_digits=10, decimal_places=2,
|
||||
label=_("Step size for reduction amount"),
|
||||
help_text=_('By default, customers can choose an arbitrary amount for you to keep. If you set this to e.g. '
|
||||
'10, they will only be able to choose values in increments of 10.')
|
||||
)
|
||||
},
|
||||
'cancel_allow_user_paid_require_approval': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -1784,19 +1801,66 @@ Your {event} team"""))
|
||||
},
|
||||
'logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
|
||||
},
|
||||
'logo_image_large': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Use header image in its full size'),
|
||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||
)
|
||||
},
|
||||
'logo_show_title': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Show event title even if a header image is present'),
|
||||
help_text=_('The title will only be shown on the event front page.'),
|
||||
)
|
||||
},
|
||||
'organizer_logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'organizer_logo_image_large': {
|
||||
'default': 'False',
|
||||
@@ -1810,11 +1874,43 @@ Your {event} team"""))
|
||||
},
|
||||
'og_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Social media image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'invoice_logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
],
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
},
|
||||
'frontpage_text': {
|
||||
'default': '',
|
||||
|
||||
@@ -82,6 +82,14 @@ class BaseDataShredder:
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def require_download_confirmation(self):
|
||||
"""
|
||||
Indicates whether the data of this shredder needs to be downloaded, before it is actually shredded. By default
|
||||
this value is equal to the tax relevant flag.
|
||||
"""
|
||||
return self.tax_relevant
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
|
||||
@@ -100,8 +100,9 @@ def truelink_callback(attrs, new=False):
|
||||
|
||||
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
|
||||
"""
|
||||
text = re.sub('[^a-zA-Z0-9.-/_]', '', attrs.get('_text')) # clean up link text
|
||||
if URL_RE.match(text):
|
||||
text = re.sub(r'[^a-zA-Z0-9.\-/_]', '', attrs.get('_text')) # clean up link text
|
||||
href_url = urllib.parse.urlparse(attrs[None, 'href'])
|
||||
if URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
# link text looks like a url
|
||||
if text.startswith('//'):
|
||||
text = 'https:' + text
|
||||
@@ -109,7 +110,6 @@ def truelink_callback(attrs, new=False):
|
||||
text = 'https://' + text
|
||||
|
||||
text_url = urllib.parse.urlparse(text)
|
||||
href_url = urllib.parse.urlparse(attrs[None, 'href'])
|
||||
if text_url.netloc != href_url.netloc or not href_url.path.startswith(href_url.path):
|
||||
# link text contains an URL that has a different base than the actual URL
|
||||
attrs['_text'] = attrs[None, 'href']
|
||||
|
||||
@@ -27,7 +27,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -35,7 +35,6 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
|
||||
class EventWizardFoundationForm(forms.Form):
|
||||
@@ -392,7 +391,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'sales_channels': CheckboxSelectMultiple()
|
||||
'sales_channels': CheckboxSelectMultiple(),
|
||||
}
|
||||
|
||||
|
||||
@@ -413,36 +412,6 @@ class EventSettingsForm(SettingsForm):
|
||||
"restrict the set of selectable titles."),
|
||||
required=False,
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Header image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('If you provide a logo image, we will by default not show your event name and date '
|
||||
'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You '
|
||||
'can increase the size with the setting below. We recommend not using small details on the picture '
|
||||
'as it will be resized on smaller screens.')
|
||||
)
|
||||
logo_image_large = forms.BooleanField(
|
||||
label=_('Use header image in its full size'),
|
||||
help_text=_('We recommend to upload a picture at least 1170 pixels wide.'),
|
||||
required=False,
|
||||
)
|
||||
logo_show_title = forms.BooleanField(
|
||||
label=_('Show event title even if a header image is present'),
|
||||
help_text=_('The title will only be shown on the event front page.'),
|
||||
required=False,
|
||||
)
|
||||
og_image = ExtFileField(
|
||||
label=_('Social media image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. '
|
||||
'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like '
|
||||
'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good '
|
||||
'only the center square is shown. If you do not fill this, we will use the logo given above.')
|
||||
)
|
||||
|
||||
auto_fields = [
|
||||
'imprint_url',
|
||||
@@ -495,6 +464,10 @@ class EventSettingsForm(SettingsForm):
|
||||
'theme_color_background',
|
||||
'theme_round_borders',
|
||||
'primary_font',
|
||||
'logo_image',
|
||||
'logo_image_large',
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
@@ -549,9 +522,6 @@ class EventSettingsForm(SettingsForm):
|
||||
if not self.event.has_subevents:
|
||||
del self.fields['frontpage_subevent_ordering']
|
||||
del self.fields['event_list_type']
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items()
|
||||
]
|
||||
|
||||
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
||||
self.virtual_keys = []
|
||||
@@ -598,6 +568,7 @@ class CancelSettingsForm(SettingsForm):
|
||||
'cancel_allow_user_paid_keep_percentage',
|
||||
'cancel_allow_user_paid_adjust_fees',
|
||||
'cancel_allow_user_paid_adjust_fees_explanation',
|
||||
'cancel_allow_user_paid_adjust_fees_step',
|
||||
'cancel_allow_user_paid_refund_as_giftcard',
|
||||
'cancel_allow_user_paid_require_approval',
|
||||
'change_allow_user_variation',
|
||||
@@ -733,6 +704,7 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'invoice_logo_image',
|
||||
]
|
||||
|
||||
invoice_generate_sales_channels = forms.MultipleChoiceField(
|
||||
@@ -752,13 +724,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
label=_("Invoice language"),
|
||||
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
||||
)
|
||||
invoice_logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
max_size=10 * 1024 * 1024,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.get('obj')
|
||||
|
||||
@@ -5,7 +5,7 @@ from urllib.parse import urlencode
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, F, Model, OuterRef, Q, QuerySet
|
||||
from django.db.models import Exists, F, Max, Model, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.formats import date_format, localize
|
||||
@@ -239,6 +239,10 @@ class OrderFilterForm(FilterForm):
|
||||
elif s == 'rc':
|
||||
qs = qs.filter(
|
||||
cancellation_requests__isnull=False
|
||||
).annotate(
|
||||
cancellation_request_time=Max('cancellation_requests__created')
|
||||
).order_by(
|
||||
'-cancellation_request_time'
|
||||
)
|
||||
elif s == 'pendingpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
@@ -458,6 +462,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
required=False,
|
||||
label=_('Total amount'),
|
||||
)
|
||||
payment_sum_min = forms.DecimalField(
|
||||
localize=True,
|
||||
required=False,
|
||||
label=_('Minimal sum of payments and refunds'),
|
||||
)
|
||||
payment_sum_max = forms.DecimalField(
|
||||
localize=True,
|
||||
required=False,
|
||||
label=_('Maximal sum of payments and refunds'),
|
||||
)
|
||||
sales_channel = forms.ChoiceField(
|
||||
label=_('Sales channel'),
|
||||
required=False,
|
||||
@@ -584,6 +598,16 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
||||
if fdata.get('locale'):
|
||||
qs = qs.filter(locale=fdata.get('locale'))
|
||||
if fdata.get('payment_sum_min') is not None:
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
computed_payment_refund_sum__gte=fdata['payment_sum_min'],
|
||||
)
|
||||
if fdata.get('payment_sum_max') is not None:
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
computed_payment_refund_sum__lte=fdata['payment_sum_max'],
|
||||
)
|
||||
if fdata.get('invoice_address_company'):
|
||||
qs = qs.filter(invoice_address__company__icontains=fdata.get('invoice_address_company'))
|
||||
if fdata.get('invoice_address_name'):
|
||||
@@ -1486,6 +1510,9 @@ class VoucherTagFilterForm(FilterForm):
|
||||
|
||||
|
||||
class RefundFilterForm(FilterForm):
|
||||
orders = {'provider': 'provider', 'state': 'state', 'order': 'order__code',
|
||||
'source': 'source', 'amount': 'amount', 'created': 'created'}
|
||||
|
||||
provider = forms.ChoiceField(
|
||||
label=_('Payment provider'),
|
||||
choices=[
|
||||
@@ -1522,6 +1549,10 @@ class RefundFilterForm(FilterForm):
|
||||
qs = qs.filter(state__in=[OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
|
||||
OrderRefund.REFUND_STATE_EXTERNAL])
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
qs = qs.order_by('-created')
|
||||
return qs
|
||||
|
||||
|
||||
|
||||
@@ -616,7 +616,7 @@ class EventCancelForm(forms.Form):
|
||||
required=False
|
||||
)
|
||||
manual_refund = forms.BooleanField(
|
||||
label=_('Create manual refund if the payment method odes not support automatic refunds'),
|
||||
label=_('Create manual refund if the payment method does not support automatic refunds'),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_auto_refund'}),
|
||||
initial=True,
|
||||
required=False,
|
||||
|
||||
@@ -22,6 +22,12 @@ from pretix.helpers.money import change_decimal_field
|
||||
class SubEventForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
instance = kwargs.get('instance')
|
||||
if instance and not instance.pk:
|
||||
kwargs['initial'].setdefault('name', self.event.name)
|
||||
kwargs['initial'].setdefault('location', self.event.location)
|
||||
kwargs['initial'].setdefault('geo_lat', self.event.geo_lat)
|
||||
kwargs['initial'].setdefault('geo_lon', self.event.geo_lon)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
data['bulk'] = True
|
||||
del data['codes']
|
||||
objs.append(obj)
|
||||
Voucher.objects.bulk_create(objs)
|
||||
Voucher.objects.bulk_create(objs, batch_size=200)
|
||||
objs = []
|
||||
for v in event.vouchers.filter(code__in=self.cleaned_data['codes']):
|
||||
# We need to query them again as bulk_create does not fill in .pk values on databases
|
||||
|
||||
@@ -292,6 +292,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.denied': _('The order has been denied.'),
|
||||
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
|
||||
'to "{new_email}".'),
|
||||
'pretix.event.order.contact.confirmed': _('The email address has been confirmed to be working (the user clicked on a link '
|
||||
'in the email for the first time).'),
|
||||
'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
|
||||
'to "{new_phone}".'),
|
||||
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
|
||||
@@ -355,6 +357,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'account.'),
|
||||
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
|
||||
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
|
||||
'pretix.control.auth.user.forgot_password.denied.repeated': _('A repeated password reset has been denied, as '
|
||||
'the last request was less than 24 hours ago.'),
|
||||
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
|
||||
|
||||
@@ -6,6 +6,13 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
def current_url(request):
|
||||
if len(request.GET):
|
||||
return request.path + '?' + request.GET.urlencode()
|
||||
else:
|
||||
return request.path
|
||||
|
||||
|
||||
def event_permission_required(permission):
|
||||
"""
|
||||
This view decorator rejects all requests with a 403 response which are not from
|
||||
@@ -94,7 +101,7 @@ def administrator_permission_required():
|
||||
raise PermissionDenied()
|
||||
if not request.user.has_active_staff_session(request.session.session_key):
|
||||
if request.user.is_staff:
|
||||
return redirect(reverse('control:user.sudo') + '?next=' + quote(request.path))
|
||||
return redirect(reverse('control:user.sudo') + '?next=' + quote(current_url(request)))
|
||||
raise PermissionDenied(_('You do not have permission to view this content.'))
|
||||
return function(request, *args, **kw)
|
||||
return wrapper
|
||||
|
||||
@@ -291,7 +291,7 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
"""
|
||||
|
||||
subevent_forms = EventPluginSignal(
|
||||
providing_args=['request', 'subevent']
|
||||
providing_args=['request', 'subevent', 'copy_from']
|
||||
)
|
||||
"""
|
||||
This signal allows you to return additional forms that should be rendered on the subevent creation
|
||||
@@ -301,7 +301,8 @@ as part of the standard validation and rendering cycle and rendered using defaul
|
||||
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
|
||||
|
||||
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
|
||||
your form instance will automatically being set to the subevent that has just been created.
|
||||
your form instance will automatically being set to the subevent that has just been created. During
|
||||
creation, ``copy_from`` can be a subevent that is being copied from.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<li>
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
{% bootstrap_field form.cancel_allow_user_paid_adjust_fees layout="control" %}
|
||||
<div data-display-dependency="#id_cancel_allow_user_paid_adjust_fees">
|
||||
{% bootstrap_field form.cancel_allow_user_paid_adjust_fees_explanation layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_adjust_fees_step layout="control" %}
|
||||
</div>
|
||||
{% bootstrap_field form.cancel_allow_user_paid_refund_as_giftcard layout="control" %}
|
||||
{% if not gets_notification %}
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
<p>
|
||||
{% trans "Your shop is currently live. If you take it down, it will only be visible to you and your team." %}
|
||||
</p>
|
||||
{% if issues|length > 0 %}
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
{% trans "Your shop is already live, however the following issues would normally prevent your shop to go live:" %}
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="text-right flip">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="live" value="false">
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
{% if form.instance.id %}
|
||||
<br><small class="text-muted">#{{ form.instance.id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</h4>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product name" %}</th>
|
||||
<th class="iconcol"></th>
|
||||
<th></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
<th class="iconcol"></th>
|
||||
@@ -50,7 +50,18 @@
|
||||
<a href="
|
||||
{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}">{{ i }}</a>
|
||||
{% if not i.active %}</strike>{% endif %}
|
||||
</strong>
|
||||
</strong>
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
#{{ i.pk }}
|
||||
{% for k, c in sales_channels.items %}
|
||||
{% if k in i.sales_channels %}
|
||||
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
|
||||
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if i.available_from or i.available_until %}
|
||||
@@ -69,6 +80,8 @@
|
||||
<td>
|
||||
{% if i.admission %}
|
||||
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
|
||||
{% elif i.issue_giftcard %}
|
||||
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
@@ -80,6 +93,9 @@
|
||||
{% if i.category.is_addon %}
|
||||
<span class="fa fa-puzzle-piece fa-fw text-muted" data-toggle="tooltip"
|
||||
title="{% trans "Only available as an add-on product" %}"></span>
|
||||
{% elif i.require_bundling %}
|
||||
<span class="fa fa-puzzle-piece fa-fw text-muted" data-toggle="tooltip"
|
||||
title="{% trans "Only available as part of a bundle" %}"></span>
|
||||
{% elif i.hide_without_voucher %}
|
||||
<span class="fa fa-tags fa-fw text-muted" data-toggle="tooltip"
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{% load rich_text %}
|
||||
{% load safelink %}
|
||||
{% load eventsignal %}
|
||||
{% load l10n %}
|
||||
{% load phone_format %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
@@ -97,7 +98,10 @@
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="start-action" value="do_nothing">
|
||||
<input type="hidden" name="start-mode" value="partial">
|
||||
<input type="hidden" name="start-partial_amount" value="{{ overpaid }}">
|
||||
{% localize off %}
|
||||
<input type="hidden" name="start-partial_amount" value="{{ overpaid|floatformat:2 }}">
|
||||
{% endlocalize %}
|
||||
<input type="hidden" name="comment" value="{% trans "Refund for overpayment" %}">
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed with amount=overpaid|money:request.event.currency %}
|
||||
This order is currently overpaid by {{ amount }}.
|
||||
@@ -759,11 +763,19 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if r.html_info %}
|
||||
{% if r.html_info or staff_session or r.comment %}
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="7">
|
||||
{{ r.html_info|safe }}
|
||||
{% if r.comment %}
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Comment" %}</dt>
|
||||
<dd>{{ r.comment }}</dd>
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if r.html_info %}
|
||||
{{ r.html_info|safe }}
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="" class="btn btn-default btn-xs" data-expandrefund
|
||||
@@ -775,17 +787,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% elif staff_session %}
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="7">
|
||||
<a href="" class="btn btn-default btn-xs" data-expandrefund
|
||||
data-id="{{ r.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -17,38 +17,41 @@
|
||||
</h1>
|
||||
<form method="post" href="">
|
||||
{% csrf_token %}
|
||||
<fieldset class="form-inline form-order-change">
|
||||
<fieldset class="form-inline form-refund-choose">
|
||||
<legend>{% trans "How should the refund be sent?" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Any payments that you selected for automatical refunds will be immediately communicate the refund
|
||||
request to the respective payment provider. Manual refunds will be created as pending refunds, you
|
||||
can then later mark them as done once you actually transferred the money back to the customer.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<h4>{% trans "Refund to original payment method" %}</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{% trans "Payment confirmation date" %}</th>
|
||||
<th>{% trans "Payment method" %}</th>
|
||||
<th>{% trans "Payment" %}</th>
|
||||
<th>{% trans "Payment details" %}</th>
|
||||
<th>{% trans "Amount not refunded" %}</th>
|
||||
<th>{% trans "Refund" %}</th>
|
||||
<th class="text-right flip refund-amount">{% trans "Refund amount" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in payments %}
|
||||
<tr>
|
||||
<td>{{ p.full_id }}</td>
|
||||
<td>{{ p.payment_date|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>
|
||||
{{ p.payment_provider.verbose_name }}
|
||||
</td>
|
||||
<td>{{ p.full_id }}<br/>{{ p.payment_date|date:"SHORT_DATETIME_FORMAT" }}<br/>{{ p.payment_provider.verbose_name }}</td>
|
||||
<td class="payment-details">{{ p.html_info|default_if_none:""|safe }}</td>
|
||||
<td>{{ p.available_amount|money:request.event.currency }}</td>
|
||||
<td>
|
||||
<td class="text-right flip refund-amount">
|
||||
{% if p.partial_refund_possible %}
|
||||
{% trans "Automatically refund" context "amount_label" %}
|
||||
<div class="input-group">
|
||||
<input type="text" name="refund-{{ p.pk }}"
|
||||
{% if p.propose_refund %}
|
||||
value="{{ p.propose_refund|floatformat:2 }}"
|
||||
value="{{ p.propose_refund|floatformat:2 }}"
|
||||
{% else %}
|
||||
placeholder="{{ p.propose_refund|floatformat:2 }}"
|
||||
placeholder="{{ p.propose_refund|floatformat:2 }}"
|
||||
{% endif %}
|
||||
title="" class="form-control">
|
||||
<span class="input-group-addon">
|
||||
@@ -60,45 +63,80 @@
|
||||
<input type="checkbox" name="refund-{{ p.pk }}"
|
||||
value="{{ p.amount|floatformat:2 }}"
|
||||
{% if p.propose_refund == p.amount %}checked{% endif %}>
|
||||
{% trans "Automatically refund full amount" %}
|
||||
{% trans "Full amount" %} ({{ p.amount|money:request.event.currency }})
|
||||
</label>
|
||||
{% else %}
|
||||
<em>{% trans "This payment method does not support automatic refunds." %}</em>
|
||||
<em class="text-muted">{% trans "This payment method does not support automatic refunds." %}</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4>{% trans "Refund to a different payment method" %}</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Payment method" %}</th>
|
||||
<th>{% trans "Recipient / options" %}</th>
|
||||
<th class="text-right flip refund-amount">{% trans "Refund amount" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for prov, form in new_refunds %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>
|
||||
{{ prov.verbose_name }}
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
{{ form|safe }}
|
||||
</td>
|
||||
<td class="text-right flip refund-amount">
|
||||
<div class="input-group">
|
||||
<input type="text" name="newrefund-{{ prov }}"
|
||||
placeholder="{{ 0|floatformat:2 }}"
|
||||
title="" class="form-control">
|
||||
<span class="input-group-addon">
|
||||
{{ request.event.currency }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><strong>{% trans "Transfer to other order" %}</strong></td>
|
||||
<td></td>
|
||||
<td>
|
||||
{% trans "Transfer" context "amount_label" %}
|
||||
<input type="text" name="order-offsetting" placeholder="{% trans "Order code" %}"
|
||||
value="" title="" class="form-control">
|
||||
</td>
|
||||
<td class="text-right flip refund-amount">
|
||||
<div class="input-group">
|
||||
<input type="text" name="refund-offsetting"
|
||||
title="" class="form-control" placeholder="{{ 0|floatformat:2 }}">
|
||||
title="" class="form-control" placeholder="{{ 0|floatformat:2 }}">
|
||||
<span class="input-group-addon">
|
||||
{{ request.event.currency }}
|
||||
</span>
|
||||
</div>
|
||||
{% trans "to" context "order_label" %}
|
||||
<input type="text" name="order-offsetting"
|
||||
value="" title="" class="form-control">
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
<strong>{% trans "Create a new gift card" %}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="text-muted">
|
||||
{% trans "The gift card can be used to buy tickets for all events of this organizer." %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right flip refund-amount">
|
||||
<div class="input-group">
|
||||
<input type="text" name="refund-new-giftcard"
|
||||
title="" class="form-control"
|
||||
title="" class="form-control"
|
||||
{% if giftcard_proposal %}
|
||||
value="{{ giftcard_proposal|floatformat:2 }}"
|
||||
{% else %}
|
||||
@@ -109,59 +147,53 @@
|
||||
{{ request.event.currency }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
{% trans "The gift card can be used to buy tickets for all events of this organizer." %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td><strong>{% trans "Manual refund" %}</strong></td>
|
||||
<td></td>
|
||||
<td>
|
||||
{% trans "Manually refund" context "amount_label" %}
|
||||
<label class="radio no-bold">
|
||||
<input type="radio" name="manual_state" value="created" checked>
|
||||
{% trans "Keep transfer as to do" %}
|
||||
</label><br>
|
||||
<label class="radio no-bold">
|
||||
<input type="radio" name="manual_state" value="done">
|
||||
{% trans "Mark refund as done" %}
|
||||
</label>
|
||||
</td>
|
||||
|
||||
<td class="text-right flip refund-amount">
|
||||
<div class="input-group">
|
||||
<input type="text" name="refund-manual"
|
||||
{% if remainder %}
|
||||
value="{{ remainder|floatformat:2 }}"
|
||||
value="{{ remainder|floatformat:2 }}"
|
||||
{% else %}
|
||||
placeholder="{{ remainder|floatformat:2 }}"
|
||||
placeholder="{{ remainder|floatformat:2 }}"
|
||||
{% endif %}
|
||||
title="" class="form-control">
|
||||
<span class="input-group-addon">
|
||||
{{ request.event.currency }}
|
||||
</span>
|
||||
</div>
|
||||
<label class="radio">
|
||||
<input type="radio" name="manual_state" value="created" checked>
|
||||
{% trans "Keep transfer as to do" %}
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="manual_state" value="done">
|
||||
{% trans "Mark refund as done" %}
|
||||
</label>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Any payments that you selected for automatical refunds will be immediately communicate the refund
|
||||
request to the respective payment provider. Manual refunds will be created as pending refunds, you
|
||||
can then later mark them as done once you actually transferred the money back to the customer.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p> </p>
|
||||
|
||||
<input type="hidden" name="start-action" value="{{ start_form.cleaned_data.action }}">
|
||||
<input type="hidden" name="start-mode" value="{{ start_form.cleaned_data.mode }}">
|
||||
<input type="hidden" name="start-partial_amount" value="{{ partial_amount }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="id_comment">{% trans "Refund reason" %}</label>
|
||||
<input type="text" name="comment" class="form-control" title="{% trans "May be shown to the end user or used e.g. as part of a payment reference." %}" id="id_comment"
|
||||
value="{{ comment|default:"" }}">
|
||||
<div class="help-block">{% trans "May be shown to the end user or used e.g. as part of a payment reference." %}</div>
|
||||
</div>
|
||||
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4">
|
||||
<a class="btn btn-block btn-default btn-lg"
|
||||
|
||||
@@ -38,12 +38,36 @@
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{% trans "Payment provider" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
<th>{% trans "Source" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th class="text-right flip">{% trans "Amount" %}</th>
|
||||
<th>
|
||||
#
|
||||
<a href="?{% url_replace request 'ordering' '-order' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'order' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Payment provider" %}
|
||||
<a href="?{% url_replace request 'ordering' '-provider' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'provider' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Start date" %}
|
||||
<a href="?{% url_replace request 'ordering' '-created' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'created' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Source" %}
|
||||
<a href="?{% url_replace request 'ordering' '-source' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'source' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th>
|
||||
{% trans "Status" %}
|
||||
<a href="?{% url_replace request 'ordering' '-state' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'state' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th class="text-right flip">
|
||||
{% trans "Amount" %}
|
||||
<a href="?{% url_replace request 'ordering' '-amount' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'amount' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</th>
|
||||
<th class="text-right flip">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
method="post" class="form-horizontal" data-asynctask>
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Step 1: Download data" %}</legend>
|
||||
{% if download_on_shred %}
|
||||
<legend>{% trans "Step 1: Download data" %}</legend>
|
||||
{% else %}
|
||||
<legend>{% trans "(Optional) Step 1: Download data" %}</legend>
|
||||
{% endif %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You are about to permanently delete data from the server, even though you might be required to
|
||||
keep
|
||||
some of this data on file. You should therefore download the following file and store it in a safe
|
||||
some of this data on file. You can therefore download the following file and store it in a safe
|
||||
place:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
@@ -27,18 +31,7 @@
|
||||
</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Step 2: Confirm download" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
In the downloaded file, there is a text file named "CONFIRM_CODE.txt" with a six-character code.
|
||||
Please enter this code here to confirm that you successfully downloaded the file.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<input type="text" class="form-control" name="confirm_code" required placeholder="{% trans "Confirmation code" %}">
|
||||
<br>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Step 3: Confirm deletion" %}</legend>
|
||||
<legend>{% trans "Step 2: Confirm deletion" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed with event=request.event.name slug=request.event.slug %}
|
||||
Please re-check that you are fully certain that you want to delete the selected categories of data from the event <strong>{{ event }}</strong>.
|
||||
@@ -46,7 +39,21 @@
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<input type="text" class="form-control" name="slug" required placeholder="{% trans "Event short name" %}">
|
||||
<br>
|
||||
</fieldset>
|
||||
{% if download_on_shred %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Step 3: Confirm download" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
In the downloaded file, there is a text file named "CONFIRM_CODE.txt" with a six-character code.
|
||||
Please enter this code here to confirm that you successfully downloaded the file.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<input type="text" class="form-control" name="confirm_code" required placeholder="{% trans "Confirmation code" %}">
|
||||
<br>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<input type="hidden" name="file" value="{{ file.pk }}">
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -332,9 +332,51 @@
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<div class="panel panel-default hidden" id="subevent_add_many_slots">
|
||||
<div class="panel-body row">
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<label for="subevent_add_many_slots_first">
|
||||
<strong>{% trans "Start of first slot" %}</strong>
|
||||
</label>
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_first" value="{{ time_begin_sample }}">
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<label for="subevent_add_many_slots_end">
|
||||
<strong>{% trans "End of time slots" %}</strong>
|
||||
</label>
|
||||
<input class="form-control timepickerfield" id="subevent_add_many_slots_end" value="{{ time_end_sample }}">
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<label for="subevent_add_many_slots_length">
|
||||
<strong>{% trans "Length of slots" %}</strong>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="subevent_add_many_slots_length" value="15">
|
||||
<span class="input-group-addon">{% trans "minutes" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-12">
|
||||
<label for="subevent_add_many_slots_break">
|
||||
<strong>{% trans "Break between slots" %}</strong>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" id="subevent_add_many_slots_break" value="0">
|
||||
<span class="input-group-addon">{% trans "minutes" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-12">
|
||||
<label> </label>
|
||||
<button class="btn-block btn btn-primary" id="subevent_add_many_slots_go" type="button">
|
||||
<span class="fa fa-check"></span> {% trans "Create" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new time slot" %}</button>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a single time slot" %}</button>
|
||||
<button type="button" class="btn btn-default" id="subevent_add_many_slots_start">
|
||||
<i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control input-xs"
|
||||
id="voucher-bulk-codes-num"
|
||||
placeholder="{% trans "Number" %}">
|
||||
placeholder="{% trans "Number" context "number_of_things" %}">
|
||||
<div class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" id="voucher-bulk-codes-generate"
|
||||
data-rng-url="{% url 'control:event.vouchers.rng' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
here immediately. If you want, you can also send them out manually right now.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if not running %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "Currently, no vouchers will be sent since your event is not live or is not selling tickets." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf.urls import include, url
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, checkin, dashboards, event, geo, global_settings, item, main, oauth,
|
||||
@@ -303,4 +304,5 @@ urlpatterns = [
|
||||
url(r'^checkinlists/(?P<list>\d+)/delete$', checkin.CheckinListDelete.as_view(),
|
||||
name='event.orders.checkinlists.delete'),
|
||||
])),
|
||||
url(r'^event/(?P<organizer>[^/]+)/$', RedirectView.as_view(pattern_name='control:organizer'), name='event.organizerredirect'),
|
||||
]
|
||||
|
||||
@@ -23,11 +23,18 @@ class GeoCodeView(LoginRequiredMixin, View):
|
||||
}, status=200)
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.opencagedata_apikey:
|
||||
res = self._use_opencage(q)
|
||||
if gs.settings.mapquest_apikey:
|
||||
res = self._use_mapquest(q)
|
||||
else:
|
||||
try:
|
||||
if gs.settings.opencagedata_apikey:
|
||||
res = self._use_opencage(q)
|
||||
elif gs.settings.mapquest_apikey:
|
||||
res = self._use_mapquest(q)
|
||||
else:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'results': []
|
||||
}, status=200)
|
||||
except IOError:
|
||||
logger.exception("Geocoding failed")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'results': []
|
||||
@@ -42,21 +49,13 @@ class GeoCodeView(LoginRequiredMixin, View):
|
||||
def _use_opencage(self, q):
|
||||
gs = GlobalSettingsObject()
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
'https://api.opencagedata.com/geocode/v1/json?q={}&key={}'.format(
|
||||
quote(q), gs.settings.opencagedata_apikey
|
||||
)
|
||||
r = requests.get(
|
||||
'https://api.opencagedata.com/geocode/v1/json?q={}&key={}'.format(
|
||||
quote(q), gs.settings.opencagedata_apikey
|
||||
)
|
||||
r.raise_for_status()
|
||||
except IOError:
|
||||
logger.exception("Geocoding failed")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'results': []
|
||||
}, status=200)
|
||||
else:
|
||||
d = r.json()
|
||||
)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
res = [
|
||||
{
|
||||
'formatted': r['formatted'],
|
||||
@@ -69,21 +68,13 @@ class GeoCodeView(LoginRequiredMixin, View):
|
||||
def _use_mapquest(self, q):
|
||||
gs = GlobalSettingsObject()
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
'http://www.mapquestapi.com/geocoding/v1/address?location={}&key={}'.format(
|
||||
quote(q), gs.settings.mapquest_apikey
|
||||
)
|
||||
r = requests.get(
|
||||
'https://www.mapquestapi.com/geocoding/v1/address?location={}&key={}'.format(
|
||||
quote(q), gs.settings.mapquest_apikey
|
||||
)
|
||||
r.raise_for_status()
|
||||
except IOError:
|
||||
logger.exception("Geocoding failed")
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'results': []
|
||||
}, status=200)
|
||||
else:
|
||||
d = r.json()
|
||||
)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
res = [
|
||||
{
|
||||
'formatted': q,
|
||||
|
||||
@@ -46,6 +46,7 @@ from pretix.control.permissions import (
|
||||
from pretix.control.signals import item_forms, item_formsets
|
||||
from pretix.helpers.models import modelcopy
|
||||
|
||||
from ...base.channels import get_all_sales_channels
|
||||
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
|
||||
|
||||
|
||||
@@ -66,6 +67,11 @@ class ItemList(ListView):
|
||||
'category__position', 'category', 'position'
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['sales_channels'] = get_all_sales_channels()
|
||||
return ctx
|
||||
|
||||
|
||||
def item_move(request, item, up=True):
|
||||
"""
|
||||
|
||||
@@ -5,11 +5,12 @@ import os
|
||||
import re
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import vat_moss.id
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
@@ -759,6 +760,7 @@ class OrderRefundView(OrderView):
|
||||
|
||||
def choose_form(self):
|
||||
payments = list(self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED))
|
||||
comment = self.request.POST.get("comment") or self.request.GET.get("comment") or None
|
||||
if self.start_form.cleaned_data.get('mode') == 'full':
|
||||
full_refund = self.order.payment_refund_sum
|
||||
else:
|
||||
@@ -800,6 +802,7 @@ class OrderRefundView(OrderView):
|
||||
else OrderRefund.REFUND_STATE_CREATED
|
||||
),
|
||||
amount=manual_value,
|
||||
comment=comment,
|
||||
provider='manual'
|
||||
))
|
||||
|
||||
@@ -827,6 +830,7 @@ class OrderRefundView(OrderView):
|
||||
execution_date=now(),
|
||||
amount=giftcard_value,
|
||||
provider='giftcard',
|
||||
comment=comment,
|
||||
info=json.dumps({
|
||||
'gift_card': giftcard.pk
|
||||
})
|
||||
@@ -857,11 +861,35 @@ class OrderRefundView(OrderView):
|
||||
execution_date=now(),
|
||||
amount=offsetting_value,
|
||||
provider='offsetting',
|
||||
comment=comment,
|
||||
info=json.dumps({
|
||||
'orders': [order.code]
|
||||
})
|
||||
))
|
||||
|
||||
for identifier, prov in self.request.event.get_payment_providers().items():
|
||||
prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0'
|
||||
prof_value = formats.sanitize_separators(prof_value)
|
||||
try:
|
||||
prof_value = Decimal(prof_value)
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
continue
|
||||
if prof_value > Decimal('0.00'):
|
||||
try:
|
||||
refund = prov.new_refund_control_form_process(self.request, prof_value, self.order)
|
||||
except ValidationError as e:
|
||||
for err in e:
|
||||
messages.error(self.request, err)
|
||||
is_valid = False
|
||||
continue
|
||||
if refund:
|
||||
refund_selected += refund.amount
|
||||
refund.comment = comment
|
||||
refund.source = OrderRefund.REFUND_SOURCE_ADMIN
|
||||
refunds.append(refund)
|
||||
|
||||
for p in payments:
|
||||
value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0'
|
||||
value = formats.sanitize_separators(value)
|
||||
@@ -891,6 +919,7 @@ class OrderRefundView(OrderView):
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
comment=comment,
|
||||
provider=p.provider
|
||||
))
|
||||
|
||||
@@ -902,7 +931,7 @@ class OrderRefundView(OrderView):
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
}, user=self.request.user)
|
||||
if r.payment or r.provider == "offsetting" or r.provider == "giftcard":
|
||||
if r.provider != "manual":
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
@@ -964,10 +993,24 @@ class OrderRefundView(OrderView):
|
||||
messages.error(self.request, _('The refunds you selected do not match the selected total refund '
|
||||
'amount.'))
|
||||
|
||||
new_refunds = []
|
||||
for identifier, prov in self.request.event.get_payment_providers().items():
|
||||
form = prov.new_refund_control_form_render(self.request, self.order)
|
||||
if form:
|
||||
new_refunds.append(
|
||||
(prov, form)
|
||||
)
|
||||
|
||||
for p in payments:
|
||||
if p.payment_provider:
|
||||
p.html_info = (p.payment_provider.payment_control_render_short(p) or "").strip()
|
||||
|
||||
return render(self.request, 'pretixcontrol/order/refund_choose.html', {
|
||||
'payments': payments,
|
||||
'new_refunds': new_refunds,
|
||||
'remainder': to_refund,
|
||||
'order': self.order,
|
||||
'comment': comment,
|
||||
'giftcard_proposal': giftcard_proposal,
|
||||
'partial_amount': (
|
||||
self.request.POST.get('start-partial_amount') if self.request.method == 'POST'
|
||||
@@ -1098,14 +1141,16 @@ class OrderTransition(OrderView):
|
||||
if self.order.pending_sum < 0:
|
||||
messages.success(self.request, _('The order has been canceled. You can now select how you want to '
|
||||
'transfer the money back to the user.'))
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}&giftcard={}'.format(
|
||||
round_decimal(self.order.pending_sum * -1),
|
||||
'true' if self.req and self.req.refund_as_giftcard else 'false'
|
||||
))
|
||||
with language(self.order.locale):
|
||||
return redirect(reverse('control:event.order.refunds.start', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.order.code
|
||||
}) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}&giftcard={}&comment={}'.format(
|
||||
round_decimal(self.order.pending_sum * -1),
|
||||
'true' if self.req and self.req.refund_as_giftcard else 'false',
|
||||
quote(gettext('Order canceled'))
|
||||
))
|
||||
|
||||
messages.success(self.request, _('The order has been canceled.'))
|
||||
elif self.order.status == Order.STATUS_PENDING and to == 'e':
|
||||
@@ -1634,10 +1679,16 @@ class OrderModifyInformation(OrderQuestionsViewMixin, OrderView):
|
||||
self.invoice_form.save()
|
||||
self.order.log_action('pretix.event.order.modified', {
|
||||
'invoice_data': self.invoice_form.cleaned_data,
|
||||
'data': [{
|
||||
k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k), File) else f.cleaned_data.get(k))
|
||||
for k in f.changed_data
|
||||
} for f in self.forms]
|
||||
'data': [
|
||||
dict(
|
||||
position=f.orderpos.pk,
|
||||
**{
|
||||
k: (f.cleaned_data.get(k).name if isinstance(f.cleaned_data.get(k),
|
||||
File) else f.cleaned_data.get(k))
|
||||
for k in f.changed_data
|
||||
}
|
||||
) for f in self.forms
|
||||
]
|
||||
}, user=request.user)
|
||||
if self.invoice_form.has_changed():
|
||||
success_message = ('The invoice address has been updated. If you want to generate a new invoice, '
|
||||
@@ -1974,7 +2025,7 @@ class OrderGo(EventPermissionRequiredMixin, View):
|
||||
try:
|
||||
return Order.objects.get(code=code, event=self.request.event)
|
||||
except Order.DoesNotExist:
|
||||
return Order.objects.get(code=Order.normalize_code(code), event=self.request.event)
|
||||
return Order.objects.get(code=Order.normalize_code(code, is_fallback=True), event=self.request.event)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
code = request.GET.get("code", "").upper().strip()
|
||||
@@ -2044,7 +2095,7 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View)
|
||||
return reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
}) + '?identifier=' + self.exporter.identifier
|
||||
|
||||
@cached_property
|
||||
def exporter(self):
|
||||
@@ -2055,14 +2106,22 @@ class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View)
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.exporter:
|
||||
messages.error(self.request, _('The selected exporter was not found.'))
|
||||
return redirect('control:event.orders.export', kwargs={
|
||||
return redirect(reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'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)
|
||||
messages.error(
|
||||
self.request,
|
||||
str(_('There was a problem processing your input:')) + ' ' + ', '.join(
|
||||
', '.join(line) for line in self.exporter.form.errors.values()
|
||||
)
|
||||
)
|
||||
return redirect(reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
}) + '?identifier=' + self.exporter.identifier)
|
||||
|
||||
cf = CachedFile(web_download=True, session_key=request.session.session_key)
|
||||
cf.date = now()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
@@ -43,8 +45,27 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
||||
template_name = 'pretixcontrol/shredder/download.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
try:
|
||||
cf = CachedFile.objects.get(pk=kwargs['file'])
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
||||
|
||||
with ZipFile(cf.file.file, 'r') as zipfile:
|
||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||
|
||||
if indexdata['organizer'] != kwargs['organizer'] or indexdata['event'] != kwargs['event']:
|
||||
raise ShredError(_("This file is from a different event."))
|
||||
|
||||
shredders = []
|
||||
for s in indexdata['shredders']:
|
||||
shredder = self.shredders.get(s)
|
||||
if not shredder:
|
||||
continue
|
||||
shredders.append(shredder)
|
||||
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['shredders'] = self.shredders
|
||||
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
||||
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
|
||||
from django.contrib import messages
|
||||
@@ -11,6 +11,7 @@ from django.forms import inlineformset_factory
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
@@ -150,7 +151,8 @@ class SubEventEditorMixin(MetaDataEditorMixin):
|
||||
@cached_property
|
||||
def plugin_forms(self):
|
||||
forms = []
|
||||
for rec, resp in subevent_forms.send(sender=self.request.event, subevent=self.object, request=self.request):
|
||||
for rec, resp in subevent_forms.send(sender=self.request.event, subevent=self.object, request=self.request,
|
||||
copy_from=self.copy_from):
|
||||
if isinstance(resp, (list, tuple)):
|
||||
forms.extend(resp)
|
||||
else:
|
||||
@@ -322,7 +324,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
|
||||
|
||||
@cached_property
|
||||
def copy_from(self):
|
||||
if self.request.GET.get("copy_from") and not getattr(self, 'object', None):
|
||||
if self.request.GET.get("copy_from") and (not getattr(self, 'object', None) or not self.object.pk):
|
||||
try:
|
||||
return self.request.event.subevents.get(pk=self.request.GET.get("copy_from"))
|
||||
except SubEvent.DoesNotExist:
|
||||
@@ -622,6 +624,10 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['rrule_formset'] = self.rrule_formset
|
||||
ctx['time_formset'] = self.time_formset
|
||||
|
||||
tf = get_format('TIME_INPUT_FORMATS')[0]
|
||||
ctx['time_begin_sample'] = time(9, 0, 0).strftime(tf)
|
||||
ctx['time_end_sample'] = time(18, 0, 0).strftime(tf)
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -327,7 +327,7 @@ class VoucherBulkCreate(EventPermissionRequiredMixin, CreateView):
|
||||
v.log_action('pretix.voucher.added', data=form.cleaned_data, user=self.request.user, save=False)
|
||||
)
|
||||
voucherids.append(v.pk)
|
||||
LogEntry.objects.bulk_create(log_entries)
|
||||
LogEntry.objects.bulk_create(log_entries, batch_size=200)
|
||||
|
||||
if form.cleaned_data['send']:
|
||||
vouchers_send.apply_async(kwargs={
|
||||
|
||||
@@ -160,20 +160,35 @@ class WaitingListView(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
quota_cache = {}
|
||||
any_avail = False
|
||||
for wle in ctx[self.context_object_name]:
|
||||
if (wle.item, wle.variation) in itemvar_cache:
|
||||
wle.availability = itemvar_cache.get((wle.item, wle.variation))
|
||||
if (wle.item, wle.variation, wle.subevent) in itemvar_cache:
|
||||
wle.availability = itemvar_cache.get((wle.item, wle.variation, wle.subevent))
|
||||
else:
|
||||
wle.availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache)
|
||||
ev = (wle.subevent or self.request.event)
|
||||
disabled = (
|
||||
not ev.presale_is_running or
|
||||
(wle.subevent and not wle.subevent.active) or
|
||||
not wle.item.is_available()
|
||||
)
|
||||
itemvar_cache[(wle.item, wle.variation)] = wle.availability
|
||||
if disabled:
|
||||
wle.availability = (0, "forbidden")
|
||||
else:
|
||||
wle.availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache)
|
||||
)
|
||||
itemvar_cache[(wle.item, wle.variation, wle.subevent)] = wle.availability
|
||||
if wle.availability[0] == 100:
|
||||
any_avail = True
|
||||
|
||||
ctx['any_avail'] = any_avail
|
||||
ctx['estimate'] = self.get_sales_estimate()
|
||||
|
||||
ctx['running'] = (
|
||||
self.request.event.live
|
||||
and (self.request.event.has_subevents or self.request.event.presale_is_running)
|
||||
)
|
||||
|
||||
return ctx
|
||||
|
||||
def get_sales_estimate(self):
|
||||
|
||||
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: 2020-12-22 11:06+0000\n"
|
||||
"POT-Creation-Date: 2021-01-27 17:45+0000\n"
|
||||
"PO-Revision-Date: 2020-07-30 19:00+0000\n"
|
||||
"Last-Translator: Abdullah <abdullah.gumaijan@gmail.com>\n"
|
||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -302,15 +302,15 @@ msgstr "الكل"
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:709
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:710
|
||||
msgid "Use a different name internally"
|
||||
msgstr "استخدام اسم مختلف داخليا"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:766
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:767
|
||||
msgid "Click to close"
|
||||
msgstr "انقر لقريب"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:781
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:782
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -385,11 +385,11 @@ msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:432
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:445
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:450
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:437
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:441
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
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: 2020-12-22 11:06+0000\n"
|
||||
"POT-Creation-Date: 2021-01-27 17:45+0000\n"
|
||||
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
|
||||
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -281,15 +281,15 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:709
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:710
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:766
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:767
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:781
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:782
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -350,11 +350,11 @@ msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:432
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:445
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:450
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:437
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:441
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
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: 2020-12-22 11:06+0000\n"
|
||||
"POT-Creation-Date: 2021-01-27 17:45+0000\n"
|
||||
"PO-Revision-Date: 2020-12-14 10:00+0000\n"
|
||||
"Last-Translator: Ondřej Sokol <osokol@treesoft.cz>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -281,15 +281,15 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:709
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:710
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:766
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:767
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:781
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:782
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -352,11 +352,11 @@ msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:432
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:445
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:450
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:437
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:441
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2020-12-22 11:06+0000\n"
|
||||
"POT-Creation-Date: 2021-01-27 17:45+0000\n"
|
||||
"PO-Revision-Date: 2020-09-15 02:00+0000\n"
|
||||
"Last-Translator: Mie Frydensbjerg <mif@aarhus.dk>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -306,15 +306,15 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Ingen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:709
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:710
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:766
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:767
|
||||
msgid "Click to close"
|
||||
msgstr "Klik for at lukke"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:781
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:782
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Du har ændringer, der ikke er gemt!"
|
||||
|
||||
@@ -385,11 +385,11 @@ msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:432
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:445
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:450
|
||||
msgid "Time zone:"
|
||||
msgstr "Tidszone:"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:437
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:441
|
||||
msgid "Your local time:"
|
||||
msgstr "Din lokaltid:"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2020-12-22 11:06+0000\n"
|
||||
"POT-Creation-Date: 2021-01-27 17:45+0000\n"
|
||||
"PO-Revision-Date: 2020-08-25 02:00+0000\n"
|
||||
"Last-Translator: Dennis Lichtenthäler <lichtenthaeler@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -301,15 +301,15 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:709
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:710
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Intern einen anderen Namen verwenden"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:766
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:767
|
||||
msgid "Click to close"
|
||||
msgstr "Klicken zum Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:781
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:782
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Sie haben ungespeicherte Änderungen!"
|
||||
|
||||
@@ -372,11 +372,11 @@ msgid "Please enter the amount the organizer can keep."
|
||||
msgstr "Bitte geben Sie den Betrag ein, den der Veranstalter einbehalten darf."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:432
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:445
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:450
|
||||
msgid "Time zone:"
|
||||
msgstr "Zeitzone:"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:437
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:441
|
||||
msgid "Your local time:"
|
||||
msgstr "Deine lokale Zeit:"
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ chardet
|
||||
charge
|
||||
Checkout
|
||||
Chrome
|
||||
Choice
|
||||
CONFIRM
|
||||
Connect
|
||||
Cronjob
|
||||
@@ -76,6 +77,7 @@ Erstattungsmethode
|
||||
Erstattungsoptionen
|
||||
Erstattungsstatus
|
||||
Erstattungsweg
|
||||
erstmalig
|
||||
Erweiterungs
|
||||
etc
|
||||
Event
|
||||
@@ -99,6 +101,8 @@ Gutscheineinlöser
|
||||
herunterscrollen
|
||||
hochlädst
|
||||
HTTPS
|
||||
IBAN
|
||||
IBANs
|
||||
iCal
|
||||
ics
|
||||
ID
|
||||
|
||||
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