Compare commits
1 Commits
release/3.
...
gha-migrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b5d721cb |
2
.github/workflows/strings.yml
vendored
@@ -4,12 +4,10 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
jobs:
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
@@ -5,12 +5,10 @@ on:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
paths-ignore:
|
||||
- 'doc/**'
|
||||
- 'src/pretix/locale/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -66,7 +64,7 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100
|
||||
run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -v -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
|
||||
@@ -92,11 +92,9 @@ Example::
|
||||
|
||||
``trust_x_forwarded_proto``
|
||||
Specifies whether the ``X-Forwarded-Proto`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
proxy that actively removes and re-adds the header to make sure the correct value is set.
|
||||
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
|
||||
Defaults to ``off``.
|
||||
|
||||
``csp_log``
|
||||
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
|
||||
@@ -30,9 +30,6 @@ position_count integer Number of ticke
|
||||
checkin_count integer Number of check-ins performed on this list (read-only).
|
||||
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
|
||||
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
|
||||
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
|
||||
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
|
||||
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
@@ -51,11 +48,6 @@ rules object Custom check-in
|
||||
|
||||
The ``auto_checkin_sales_channels`` field has been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``subevent`` attribute may now be ``null`` inside event series. The ``allow_multiple_entries``,
|
||||
``allow_entry_after_exit``, and ``rules`` attributes have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -97,9 +89,6 @@ Endpoints
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"rules": {},
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -144,9 +133,6 @@ Endpoints
|
||||
"limit_products": [],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"rules": {},
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -243,8 +229,6 @@ Endpoints
|
||||
"all_products": false,
|
||||
"limit_products": [1, 2],
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -267,8 +251,6 @@ Endpoints
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -321,8 +303,6 @@ Endpoints
|
||||
"limit_products": [1, 2],
|
||||
"include_pending": false,
|
||||
"subevent": null,
|
||||
"allow_multiple_entries": false,
|
||||
"allow_entry_after_exit": true,
|
||||
"auto_checkin_sales_channels": [
|
||||
"pretixpos"
|
||||
]
|
||||
@@ -716,7 +696,6 @@ Order position endpoints
|
||||
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
* ``rules`` - Check-in prevented by a user-defined rule
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
|
||||
@@ -526,7 +526,7 @@ information about the properties.
|
||||
|
||||
Get current values of event settings.
|
||||
|
||||
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
|
||||
@@ -155,10 +155,6 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``reactivate`` operation has been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``search`` query parameter has been added.
|
||||
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
@@ -199,7 +195,6 @@ pseudonymization_id string A random ID, e.
|
||||
checkins list of objects List of check-ins with this ticket
|
||||
├ list integer Internal ID of the check-in list
|
||||
├ datetime datetime Time of check-in
|
||||
├ type string Type of scan (defaults to ``entry``)
|
||||
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
|
||||
downloads list of objects List of ticket download options
|
||||
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
|
||||
@@ -256,10 +251,6 @@ pdf_data object Data object req
|
||||
|
||||
The attributes ``company``, ``street``, ``zipcode``, ``city``, ``country``, and ``state`` have been added.
|
||||
|
||||
.. versionchanged:: 3.9
|
||||
|
||||
The ``checkin.type`` attribute has been added.
|
||||
|
||||
.. _order-payment-resource:
|
||||
|
||||
Order payment resource
|
||||
@@ -422,7 +413,6 @@ List of all orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -472,7 +462,6 @@ List of all orders
|
||||
``last_modified``, and ``status``. Default: ``datetime``
|
||||
:query string code: Only return orders that match the given order code
|
||||
:query string status: Only return orders in the given order status (see above)
|
||||
:query string search: Only return orders matching a given search query
|
||||
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
|
||||
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
|
||||
``require_approval`` will be returned.
|
||||
@@ -586,7 +575,6 @@ Fetching individual orders
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -1483,7 +1471,6 @@ List of all order positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
@@ -1589,7 +1576,6 @@ Fetching individual positions
|
||||
"checkins": [
|
||||
{
|
||||
"list": 44,
|
||||
"type": "entry",
|
||||
"datetime": "2017-12-25T12:45:23Z",
|
||||
"auto_checked_in": false
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ close_when_sold_out boolean If ``true``, th
|
||||
again.
|
||||
closed boolean Whether the quota is currently closed (see above
|
||||
field).
|
||||
release_after_exit boolean Whether the quota regains capacity as soon as some tickets
|
||||
have been scanned at an exit.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 1.10
|
||||
@@ -38,10 +36,6 @@ release_after_exit boolean Whether the quo
|
||||
|
||||
The attributes ``close_when_sold_out`` and ``closed`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The attribute ``release_after_exit`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -289,7 +283,6 @@ Endpoints
|
||||
"total_size": 1000,
|
||||
"pending_orders": 25,
|
||||
"paid_orders": 423,
|
||||
"exited_orders": 0,
|
||||
"cart_positions": 7,
|
||||
"blocking_vouchers": 126,
|
||||
"waiting_list": 0
|
||||
|
||||
@@ -39,12 +39,10 @@ geo_lon float Longitude of th
|
||||
item_price_overrides list of objects List of items for which this sub-event overrides the
|
||||
default price
|
||||
├ item integer The internal item ID
|
||||
├ disabled boolean If ``true``, item should not be available for this sub-event
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
variation_price_overrides list of objects List of variations for which this sub-event overrides
|
||||
the default price
|
||||
├ variation integer The internal variation ID
|
||||
├ disabled boolean If ``true``, variation should not be available for this sub-event
|
||||
└ price money (string) The price or ``null`` for the default price
|
||||
meta_data object Values set for organizer-specific meta data parameters.
|
||||
seating_plan integer If reserved seating is in use, the ID of a seating
|
||||
@@ -76,10 +74,6 @@ seat_category_mapping object An object mappi
|
||||
|
||||
The attributes ``geo_lat`` and ``geo_lon`` have been added.
|
||||
|
||||
.. versionchanged:: 3.10
|
||||
|
||||
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -131,7 +125,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -189,7 +182,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -224,7 +216,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -280,7 +271,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
@@ -317,7 +307,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
@@ -350,7 +339,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "23.42"
|
||||
}
|
||||
],
|
||||
@@ -439,7 +427,6 @@ Endpoints
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"disabled": false,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -70,9 +70,6 @@ and ``checkin_list``.
|
||||
only include the minimum amount of data necessary for you to fetch the changed objects from our
|
||||
:ref:`rest-api` in an authenticated way.
|
||||
|
||||
.. warning:: In very rare cases, you could receive the same webhook notification twice. We try to avoid it, but we
|
||||
prefer it over missing a notification.
|
||||
|
||||
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
|
||||
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
|
||||
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
|
||||
|
||||
@@ -29,22 +29,6 @@ that we'll provide in this plugin::
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
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::
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.signals import register_multievent_data_exporters
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multieventexporter_myexporter")
|
||||
def register_multievent_data_exporter(sender, **kwargs):
|
||||
from .exporter import MyExporter
|
||||
return MyExporter
|
||||
|
||||
If your exporter supports both event-level and multi-event level exports, you will need to listen for both
|
||||
signals.
|
||||
|
||||
The exporter class
|
||||
------------------
|
||||
|
||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
@@ -1,94 +0,0 @@
|
||||
.. _timeslots:
|
||||
|
||||
Use case: Time slots
|
||||
====================
|
||||
|
||||
A more advanced use case of pretix is using pretix for time-slot-based access to an area with a limited visitor
|
||||
capacity, such as a museum or other attraction. This guide will show you the quickest way to set up such an event
|
||||
with pretix.
|
||||
|
||||
First of all, when creating your event, you need to select that your event represents an "event series":
|
||||
|
||||
|
||||
.. thumbnail:: ../../../screens/event/create_step1.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
You can click :ref:`here <subevents>` for a more general description of event series with pretix, but everything you
|
||||
need to know is in this chapter as well.
|
||||
|
||||
General event setup
|
||||
-------------------
|
||||
|
||||
Before you go further, set up your products that you want to sell for each time slot, such as different types of entry.
|
||||
|
||||
Creating slots
|
||||
--------------
|
||||
|
||||
To create the time slots, you need to create a number of "dates" in the event series. Select "Dates" in the navigation
|
||||
menu on the left side and click "Create many new dates". Then, first enter the pattern of your opening days. In the
|
||||
example, the museum is open week Tuesday to Sunday. We recommend to create the slots for a few weeks at a time, but not
|
||||
e.g. for a full year, since it will be more complicated to change things later.
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Then, scroll to the times section and create your time slots. You can do any interval you like. If you have different
|
||||
opening times on different week days, you will need to go through the creation process multiple times.
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create_2.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Scroll further down and create one or multiple quotas that define how many people can book a ticket for that time slot.
|
||||
In this example, 50 people in total are allowed to enter within every slot:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_create_3.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Do **not** create a check-in list at this point. We will deal with this further below in the guide.
|
||||
Now, press "Save" to create your slots.
|
||||
|
||||
.. warning:: If you create a lot of time slots at once, the server might need a few minutes to create them all in our
|
||||
system. If you receive an error page because it took too long, please do not try again immediately but wait
|
||||
for a few minutes. Most likely, the slots will be created successfully even though you saw an error.
|
||||
|
||||
Event settings
|
||||
--------------
|
||||
|
||||
We recommend that you navigate to "Settings" > "General" > "Display" and set the settings "Default overview style"
|
||||
to "Week calendar":
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_settings_1.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Now, your ticket shop should give users a nice weekly overview over all time slots and their availability:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_presale.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Check-in
|
||||
--------
|
||||
|
||||
If you want to scan people at the entrance of your event and only admit them at their designated time, we recommend
|
||||
the following setup: Go to "Check-in" in the main navigation on the left and create a new check-in list. Give it a name
|
||||
and do *not* choose a specific data. We will use one check-in list for all dates. Then, go to the "Advanced" tab at
|
||||
the top and set up two restrictions to make sure people can only get in during the time slot they registered for.
|
||||
You can create the rules exactly like shown in the following screenshot:
|
||||
|
||||
.. thumbnail:: ../../../screens/event/timeslots_checkinlists.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
If you want, you can enter a tolerance of e.g. "10" if you want to be a little bit more relaxed and admit people up to
|
||||
10 minutes before or after their time slot.
|
||||
|
||||
Now, download our `Android or Desktop app`_ and register it to your account. The app will ask you to select one the
|
||||
time slots, but it does not matter, you can select any one of them and then select your newly created check-in list.
|
||||
That's it, you're good to go!
|
||||
|
||||
.. _Android or Desktop app: https://pretix.eu/about/en/scan
|
||||
@@ -344,13 +344,3 @@ In addition to your normal conference quota, you need to create an unlimited quo
|
||||
Then, head to the **Bundled products** tab of the "conference ticket" and add the "conference food" as a bundled product with a **designated price** of € 150.
|
||||
|
||||
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
|
||||
|
||||
You can find more use cases in these specialized guides:
|
||||
|
||||
More use cases
|
||||
--------------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
guides/timeslots
|
||||
|
||||
@@ -136,15 +136,10 @@ If you want to include all your public events, you can just reference your organ
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/"></pretix-widget>
|
||||
|
||||
There is an optional ``style`` parameter that let's you choose between a monthly calendar view, a week view and a list
|
||||
view. If you do not set it, the choice will be taken from your organizer settings::
|
||||
There is an optional ``style`` parameter that let's you choose between a calendar view and a list view. If you do not set it, the choice will be taken from your organizer settings::
|
||||
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="list"></pretix-widget>
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
|
||||
<pretix-widget event="https://pretix.eu/demo/series/" style="week"></pretix-widget>
|
||||
|
||||
If you have more than 100 events, the system might refuse to show a list view and always show a calendar for performance
|
||||
reasons instead.
|
||||
|
||||
You can see an example here:
|
||||
|
||||
|
||||
@@ -58,6 +58,28 @@ method without creating a new order. If payment deadlines were dependent on the
|
||||
forth could either allow someone to extend their deadline forever, or render someones order invalid by moving the date
|
||||
back in the past.
|
||||
|
||||
How can I revert a check-in?
|
||||
----------------------------
|
||||
|
||||
Neither our apps nor our web interface can currently undo the check-in of a tickets. We know that this is
|
||||
inconvenient for some of you, but we have a good reason for it:
|
||||
|
||||
Our Desktop and Android apps both support an asynchronous mode in which they can scan tickets while staying
|
||||
independent of their internet connection. When scanning with multiple devices, it can of course happen that two
|
||||
devices scan the same ticket without knowing of the other scan. As soon as one of the devices regains connectivity, it
|
||||
will upload its activity and the server marks the ticket as checked in -- regardless of the order in which the two
|
||||
scans were made and uploaded (which could be two different orders).
|
||||
|
||||
If we'd provide a "check out" feature, it would not only be used to fix an accidental scan, but scan at entry and
|
||||
exit to count the current number of people inside etc. In this case, the order of operations matters very much for them
|
||||
to make sense and provide useful results. This makes implementing an asynchronous mode much more complicated.
|
||||
|
||||
In this trade off, we chose offline-capabilities over the check out feature. We plan on solving this problem in the
|
||||
future, but we're not there yet.
|
||||
|
||||
If you're just *testing* the check-in capabilities and want to clean out everything for the real process, you can just
|
||||
delete and re-create the check-in list.
|
||||
|
||||
Why does pretix not support any 1D (linear) bar codes?
|
||||
------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
General settings
|
||||
================
|
||||
|
||||
At "Settings" → "Payment", you can configure every aspect related to the payments you want to accept. The "Deadline"
|
||||
and "Advanced" tabs of the page show a number of general settings that affect all payment methods:
|
||||
At "Settings" → "Payment", you can configure every aspect related to the payments you want to accept. The upper part
|
||||
of the page shows a number of general settings that affect all payment methods:
|
||||
|
||||
.. thumbnail:: ../../screens/event/settings_payment.png
|
||||
:align: center
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.10.0"
|
||||
__version__ = "3.9.0.dev0"
|
||||
|
||||
@@ -14,8 +14,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = CheckinList
|
||||
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
|
||||
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
|
||||
'rules')
|
||||
'include_pending', 'auto_checkin_sales_channels')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
@@ -29,7 +28,9 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
if event.has_subevents:
|
||||
if full_data.get('subevent') and event != full_data.get('subevent').event:
|
||||
if not full_data.get('subevent'):
|
||||
raise ValidationError(_('Subevent cannot be null for event series.'))
|
||||
if event != full_data.get('subevent').event:
|
||||
raise ValidationError(_('The subevent does not belong to this event.'))
|
||||
else:
|
||||
if full_data.get('subevent'):
|
||||
|
||||
@@ -341,13 +341,13 @@ class CloneEventSerializer(EventSerializer):
|
||||
class SubEventItemSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItem
|
||||
fields = ('item', 'price', 'disabled')
|
||||
fields = ('item', 'price')
|
||||
|
||||
|
||||
class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = SubEventItemVariation
|
||||
fields = ('variation', 'price', 'disabled')
|
||||
fields = ('variation', 'price')
|
||||
|
||||
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
@@ -555,7 +555,6 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'meta_noindex',
|
||||
'redirect_to_checkout_directly',
|
||||
'frontpage_subevent_ordering',
|
||||
'event_list_type',
|
||||
'frontpage_text',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
@@ -611,7 +610,6 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
'invoice_introductory_text',
|
||||
'invoice_additional_text',
|
||||
'invoice_footer_text',
|
||||
'invoice_eu_currencies',
|
||||
'cancel_allow_user',
|
||||
'cancel_allow_user_until',
|
||||
'cancel_allow_user_paid',
|
||||
@@ -630,13 +628,9 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
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'](
|
||||
@@ -665,40 +659,3 @@ class EventSettingsSerializer(serializers.Serializer):
|
||||
settings_dict.update(data)
|
||||
validate_settings(self.event, settings_dict)
|
||||
return data
|
||||
|
||||
|
||||
class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
default_fields = [
|
||||
'locales',
|
||||
'locale',
|
||||
'last_order_modification_date',
|
||||
'show_quota_left',
|
||||
'max_items_per_order',
|
||||
'attendee_names_asked',
|
||||
'attendee_names_required',
|
||||
'attendee_emails_asked',
|
||||
'attendee_emails_required',
|
||||
'attendee_addresses_asked',
|
||||
'attendee_addresses_required',
|
||||
'attendee_company_asked',
|
||||
'attendee_company_required',
|
||||
'ticket_download',
|
||||
'ticket_download_addons',
|
||||
'ticket_download_nonadm',
|
||||
'ticket_download_pending',
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
'invoice_name_required',
|
||||
'invoice_address_not_asked_free',
|
||||
'invoice_address_from_name',
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
]
|
||||
|
||||
@@ -349,7 +349,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Quota
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out', 'release_after_exit')
|
||||
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -68,7 +68,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
data['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):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country')):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
@@ -122,7 +122,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
||||
class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('datetime', 'list', 'auto_checked_in', 'type')
|
||||
fields = ('datetime', 'list', 'auto_checked_in')
|
||||
|
||||
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
@@ -416,26 +416,16 @@ class OrderSerializer(I18nAwareModelSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class SimulatedOrderPositionSerializer(OrderPositionSerializer):
|
||||
addon_to = serializers.SlugRelatedField(read_only=True, slug_field='positionid')
|
||||
|
||||
|
||||
class SimulatedOrderSerializer(OrderSerializer):
|
||||
positions = SimulatedOrderPositionSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
class PriceCalcSerializer(serializers.Serializer):
|
||||
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
|
||||
tax_rule = serializers.PrimaryKeyRelatedField(queryset=TaxRule.objects.none(), required=False, allow_null=True)
|
||||
locale = serializers.CharField(allow_null=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['item'].queryset = event.items.all()
|
||||
self.fields['tax_rule'].queryset = event.tax_rules.all()
|
||||
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
|
||||
if event.has_subevents:
|
||||
self.fields['subevent'].queryset = event.subevents.all()
|
||||
@@ -600,7 +590,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
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):
|
||||
if not pycountry.countries.get(alpha_2=data.get('country')):
|
||||
raise ValidationError(
|
||||
{'country': ['Invalid country code.']}
|
||||
)
|
||||
@@ -913,19 +903,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
|
||||
continue
|
||||
|
||||
if pos_data.get('subevent'):
|
||||
if pos_data.get('item').pk in pos_data['subevent'].item_overrides and pos_data['subevent'].item_overrides[pos_data['item'].pk].disabled:
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
if (
|
||||
pos_data.get('variation') and pos_data['variation'].pk in pos_data['subevent'].var_overrides and
|
||||
pos_data['subevent'].var_overrides[pos_data['variation'].pk].disabled
|
||||
):
|
||||
errs[i]['item'] = [gettext_lazy('The product "{}" is not available on this date.').format(
|
||||
str(pos_data.get('item'))
|
||||
)]
|
||||
|
||||
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
|
||||
if pos_data.get('variation')
|
||||
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
|
||||
@@ -986,10 +963,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
else:
|
||||
pos.order = order
|
||||
if addon_to:
|
||||
if simulate:
|
||||
pos.addon_to = pos_map[addon_to]._wrapped
|
||||
else:
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
pos.addon_to = pos_map[addon_to]
|
||||
|
||||
if pos.price is None:
|
||||
price = get_price(
|
||||
|
||||
@@ -41,8 +41,8 @@ class ConditionalListView:
|
||||
return super().list(request, **kwargs)
|
||||
|
||||
lmd = request.event.logentry_set.filter(
|
||||
content_type__model=self.get_queryset().model._meta.model_name,
|
||||
content_type__app_label=self.get_queryset().model._meta.app_label,
|
||||
content_type__model=self.queryset.model._meta.model_name,
|
||||
content_type__app_label=self.queryset.model._meta.app_label,
|
||||
).aggregate(
|
||||
m=Max('datetime')
|
||||
)['m']
|
||||
|
||||
@@ -88,9 +88,8 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
pqs = OrderPosition.objects.filter(
|
||||
order__event=clist.event,
|
||||
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
|
||||
subevent=clist.subevent,
|
||||
)
|
||||
if clist.subevent:
|
||||
pqs = pqs.filter(subevent=clist.subevent)
|
||||
if not clist.all_products:
|
||||
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
|
||||
cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True))
|
||||
@@ -202,13 +201,10 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
)
|
||||
if self.checkinlist.subevent:
|
||||
qs = qs.filter(
|
||||
subevent=self.checkinlist.subevent
|
||||
)
|
||||
|
||||
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
|
||||
qs = qs.filter(
|
||||
@@ -255,9 +251,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@action(detail=True, methods=['POST'])
|
||||
def redeem(self, *args, **kwargs):
|
||||
force = bool(self.request.data.get('force', False))
|
||||
type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
|
||||
if type not in dict(Checkin.CHECKIN_TYPES):
|
||||
raise ValidationError("Invalid check-in type.")
|
||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||
nonce = self.request.data.get('nonce')
|
||||
op = self.get_object(ignore_status=True)
|
||||
@@ -290,7 +283,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
canceled_supported=self.request.data.get('canceled_supported', False),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
type=type,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
|
||||
@@ -10,8 +10,8 @@ from rest_framework.response import Response
|
||||
|
||||
from pretix.api.auth.permission import EventCRUDPermission
|
||||
from pretix.api.serializers.event import (
|
||||
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
|
||||
EventSettingsSerializer, SubEventSerializer, TaxRuleSerializer,
|
||||
CloneEventSerializer, EventSerializer, EventSettingsSerializer,
|
||||
SubEventSerializer, TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
@@ -337,16 +337,10 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class EventSettingsView(views.APIView):
|
||||
permission = None
|
||||
write_permission = 'can_change_event_settings'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if isinstance(request.auth, Device):
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
elif 'can_change_event_settings' in request.eventpermset:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event)
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
|
||||
@@ -20,7 +20,6 @@ from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
with scopes_disabled():
|
||||
@@ -534,18 +533,14 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
def availability(self, request, *args, **kwargs):
|
||||
quota = self.get_object()
|
||||
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(quota)
|
||||
qa.compute()
|
||||
avail = qa.results[quota]
|
||||
avail = quota.availability()
|
||||
|
||||
data = {
|
||||
'paid_orders': qa.count_paid_orders[quota],
|
||||
'pending_orders': qa.count_pending_orders[quota],
|
||||
'exited_orders': qa.count_exited_orders[quota],
|
||||
'blocking_vouchers': qa.count_vouchers[quota],
|
||||
'cart_positions': qa.count_cart[quota],
|
||||
'waiting_list': qa.count_pending_orders[quota],
|
||||
'paid_orders': quota.count_paid_orders(),
|
||||
'pending_orders': quota.count_pending_orders(),
|
||||
'blocking_vouchers': quota.count_blocking_vouchers(),
|
||||
'cart_positions': quota.count_in_cart(),
|
||||
'waiting_list': quota.count_waiting_list_pending(),
|
||||
'available_number': avail[1],
|
||||
'available': avail[0] == Quota.AVAILABILITY_OK,
|
||||
'total_size': quota.size,
|
||||
|
||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -26,7 +26,7 @@ from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
|
||||
OrderPaymentSerializer, OrderPositionSerializer,
|
||||
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
|
||||
PriceCalcSerializer, SimulatedOrderSerializer,
|
||||
PriceCalcSerializer,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
@@ -52,7 +52,6 @@ from pretix.base.signals import (
|
||||
order_modified, order_paid, order_placed, register_ticket_outputs,
|
||||
)
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
@@ -61,48 +60,11 @@ with scopes_disabled():
|
||||
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
|
||||
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
|
||||
|
||||
def search_qs(self, qs, name, value):
|
||||
u = value
|
||||
if "-" in value:
|
||||
code = (Q(event__slug__icontains=u.rsplit("-", 1)[0])
|
||||
& Q(code__icontains=Order.normalize_code(u.rsplit("-", 1)[1])))
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
|
||||
)
|
||||
).values('id')
|
||||
|
||||
mainq = (
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
| Q(invoice_address__name_cached__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(comment__icontains=u)
|
||||
| Q(has_pos=True)
|
||||
)
|
||||
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
|
||||
mainq = mainq | q
|
||||
return qs.annotate(has_pos=Exists(matching_positions)).filter(
|
||||
mainq
|
||||
)
|
||||
|
||||
|
||||
class OrderViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = OrderSerializer
|
||||
@@ -526,12 +488,10 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
self.perform_create(serializer)
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
if not order.pk:
|
||||
# Simulation
|
||||
serializer = SimulatedOrderSerializer(order, context=serializer.context)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
order.log_action(
|
||||
'pretix.event.order.placed',
|
||||
@@ -783,8 +743,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
{
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"subevent": 3,
|
||||
"tax_rule": 4,
|
||||
"subevent": 3
|
||||
}
|
||||
|
||||
Sample output:
|
||||
@@ -838,11 +797,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
if data.get('subevent'):
|
||||
kwargs['subevent'] = data.get('subevent')
|
||||
|
||||
if data.get('tax_rule'):
|
||||
kwargs['tax_rule'] = data.get('tax_rule')
|
||||
|
||||
price = get_price(**kwargs)
|
||||
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
|
||||
with language(data.get('locale') or self.request.event.settings.locale):
|
||||
return Response({
|
||||
'gross': price.gross,
|
||||
@@ -851,7 +806,6 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
'rate': price.rate,
|
||||
'name': str(price.name),
|
||||
'tax': price.tax,
|
||||
'tax_rule': tr.pk if tr else None,
|
||||
})
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
|
||||
@@ -168,9 +168,9 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask, acks_late=True)
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def notify_webhooks(logentry_id: int):
|
||||
logentry = LogEntry.all.select_related('event', 'event__organizer').get(id=logentry_id)
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
|
||||
if not logentry.organizer:
|
||||
return # We need to know the organizer
|
||||
@@ -205,7 +205,7 @@ def notify_webhooks(logentry_id: int):
|
||||
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9, acks_late=True)
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9)
|
||||
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
# 9 retries with 2**(2*x) timing is roughly 72 hours
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -6,14 +6,11 @@ from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
class BaseExporter:
|
||||
"""
|
||||
@@ -22,12 +19,6 @@ class BaseExporter:
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
self.is_multievent = isinstance(event, QuerySet)
|
||||
if isinstance(event, QuerySet):
|
||||
self.events = event
|
||||
self.event = None
|
||||
else:
|
||||
self.events = Event.objects.filter(pk=event.pk)
|
||||
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
@@ -9,14 +9,11 @@ from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Invoice, OrderPayment
|
||||
from pretix.base.models import OrderPayment
|
||||
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ..exporter import BaseExporter
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class InvoiceExporter(BaseExporter):
|
||||
@@ -24,7 +21,7 @@ class InvoiceExporter(BaseExporter):
|
||||
verbose_name = _('All invoices')
|
||||
|
||||
def render(self, form_data: dict, output_file=None):
|
||||
qs = Invoice.objects.filter(event__in=self.events, shredded=False)
|
||||
qs = self.event.invoices.filter(shredded=False)
|
||||
|
||||
if form_data.get('payment_provider'):
|
||||
qs = qs.annotate(
|
||||
@@ -71,16 +68,11 @@ class InvoiceExporter(BaseExporter):
|
||||
if not any:
|
||||
return None
|
||||
|
||||
if self.is_multievent:
|
||||
filename = '{}_invoices.zip'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
filename = '{}_invoices.zip'.format(self.event.slug)
|
||||
|
||||
if output_file:
|
||||
return filename, 'application/zip', None
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None
|
||||
else:
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return filename, 'application/zip', zipf.read()
|
||||
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
@@ -107,8 +99,6 @@ class InvoiceExporter(BaseExporter):
|
||||
label=_('Payment provider'),
|
||||
choices=[
|
||||
('', _('All payment providers')),
|
||||
] + get_all_payment_providers() if self.is_multievent else [
|
||||
('', _('All payment providers')),
|
||||
] + [
|
||||
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
|
||||
],
|
||||
@@ -125,8 +115,3 @@ class InvoiceExporter(BaseExporter):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoices")
|
||||
def register_invoice_export(sender, **kwargs):
|
||||
return InvoiceExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoices")
|
||||
def register_multievent_invoice_export(sender, **kwargs):
|
||||
return InvoiceExporter
|
||||
|
||||
@@ -8,9 +8,7 @@ from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class MailExporter(BaseExporter):
|
||||
@@ -18,18 +16,14 @@ class MailExporter(BaseExporter):
|
||||
verbose_name = _('Email addresses (text file)')
|
||||
|
||||
def render(self, form_data: dict):
|
||||
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
|
||||
qs = self.event.orders.filter(status__in=form_data['status'])
|
||||
addrs = qs.values('email')
|
||||
pos = OrderPosition.objects.filter(
|
||||
order__event__in=self.events, order__status__in=form_data['status']
|
||||
order__event=self.event, order__status__in=form_data['status']
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs if a['email'])
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
|
||||
if self.is_multievent:
|
||||
return '{}_pretixemails.txt'.format(self.events.first().organizer.slug), 'text/plain', data.encode("utf-8")
|
||||
else:
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
@@ -50,8 +44,3 @@ class MailExporter(BaseExporter):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_mail")
|
||||
def register_mail_export(sender, **kwargs):
|
||||
return MailExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_mail")
|
||||
def register_multievent_mail_export(sender, **kwargs):
|
||||
return MailExporter
|
||||
|
||||
@@ -4,38 +4,26 @@ from decimal import Decimal
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import (
|
||||
CharField, Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery,
|
||||
Sum,
|
||||
Count, DateTimeField, F, IntegerField, Max, OuterRef, Subquery, Sum,
|
||||
)
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
GiftCard, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
Question,
|
||||
GiftCard, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = gettext_lazy('Order data')
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
return dict(get_all_payment_providers())
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
@@ -61,13 +49,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
tax_rates = set(
|
||||
a for a
|
||||
in OrderFee.objects.filter(
|
||||
order__event__in=self.events
|
||||
order__event=self.event
|
||||
).values_list('tax_rate', flat=True).distinct().order_by()
|
||||
)
|
||||
tax_rates |= set(
|
||||
a for a
|
||||
in OrderPosition.objects.filter(
|
||||
order__event__in=self.events
|
||||
order__event=self.event
|
||||
).values_list('tax_rate', flat=True).distinct().order_by()
|
||||
)
|
||||
tax_rates = sorted(tax_rates)
|
||||
@@ -82,6 +70,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
return self.iterate_fees(form_data)
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
p_date = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
@@ -91,34 +81,24 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(k=Count('id')).values('k')
|
||||
qs = Order.objects.filter(event__in=self.events).annotate(
|
||||
qs = self.event.orders.annotate(
|
||||
payment_date=Subquery(p_date, output_field=DateTimeField()),
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
pcnt=Subquery(s, output_field=IntegerField())
|
||||
).select_related('invoice_address').prefetch_related('invoices').prefetch_related('event')
|
||||
).select_related('invoice_address').prefetch_related('invoices')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
@@ -138,7 +118,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Requires special attention'))
|
||||
headers.append(_('Comment'))
|
||||
headers.append(_('Positions'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
yield headers
|
||||
|
||||
@@ -160,10 +139,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
}
|
||||
|
||||
for order in qs.order_by('datetime'):
|
||||
tz = pytz.timezone(order.event.settings.timezone)
|
||||
|
||||
row = [
|
||||
order.event.slug,
|
||||
order.code,
|
||||
order.total,
|
||||
order.get_status_display(),
|
||||
@@ -175,7 +151,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
@@ -190,7 +166,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
@@ -217,32 +193,18 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
row.append(_('Yes') if order.checkin_attention else _('No'))
|
||||
row.append(order.comment or "")
|
||||
row.append(order.pcnt)
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((order.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderFee.objects.filter(
|
||||
order__event__in=self.events,
|
||||
).annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
order__event=self.event,
|
||||
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
@@ -256,22 +218,19 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
|
||||
headers.append(_('Payment providers'))
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime'):
|
||||
order = op.order
|
||||
tz = pytz.timezone(order.event.settings.timezone)
|
||||
row = [
|
||||
order.event.slug,
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
@@ -288,7 +247,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
@@ -303,27 +262,14 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event__in=self.events,
|
||||
).annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
order__event=self.event,
|
||||
).select_related(
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
@@ -334,14 +280,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Event slug'),
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
]
|
||||
if self.events.filter(has_subevents=True).exists():
|
||||
if self.event.has_subevents:
|
||||
headers.append(pgettext('subevent', 'Date'))
|
||||
headers.append(_('Start date'))
|
||||
headers.append(_('End date'))
|
||||
@@ -354,8 +299,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Tax value'),
|
||||
_('Attendee name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
@@ -369,8 +314,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
|
||||
questions = list(Question.objects.filter(event__in=self.events))
|
||||
questions = list(self.event.questions.all())
|
||||
options = {}
|
||||
for q in questions:
|
||||
if q.type == Question.TYPE_CHOICE_MULTIPLE:
|
||||
@@ -384,35 +328,30 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
|
||||
]
|
||||
headers += [
|
||||
_('Sales channel'), _('Order locale'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
headers.append(_('Sales channel'))
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime', 'positionid'):
|
||||
order = op.order
|
||||
tz = pytz.timezone(order.event.settings.timezone)
|
||||
row = [
|
||||
order.event.slug,
|
||||
order.code,
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
]
|
||||
if order.event.has_subevents:
|
||||
if self.event.has_subevents:
|
||||
row.append(op.subevent.name)
|
||||
row.append(op.subevent.date_from.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
if op.subevent.date_to:
|
||||
row.append(op.subevent.date_to.astimezone(order.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
|
||||
else:
|
||||
row.append('')
|
||||
row += [
|
||||
@@ -424,7 +363,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
op.tax_value,
|
||||
op.attendee_name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
op.attendee_name_parts.get(k, '')
|
||||
@@ -462,7 +401,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
@@ -477,22 +416,12 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
|
||||
row += [
|
||||
order.sales_channel,
|
||||
order.locale
|
||||
]
|
||||
row.append(', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]))
|
||||
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
row.append(order.sales_channel)
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_orders'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
@@ -503,48 +432,47 @@ class PaymentListExporter(ListExporter):
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('payment_states',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Payment states'),
|
||||
choices=OrderPayment.PAYMENT_STATES,
|
||||
initial=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED],
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)),
|
||||
('refund_states',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Refund states'),
|
||||
choices=OrderRefund.REFUND_STATES,
|
||||
initial=[OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_CREATED,
|
||||
OrderRefund.REFUND_STATE_TRANSIT],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
('successful_only',
|
||||
forms.BooleanField(
|
||||
label=_('Only successful payments'),
|
||||
initial=True,
|
||||
required=False
|
||||
)),
|
||||
]
|
||||
)
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
provider_names = dict(get_all_payment_providers())
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
provider_names = {
|
||||
k: v.verbose_name
|
||||
for k, v in self.event.get_payment_providers().items()
|
||||
}
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('payment_states', [])
|
||||
order__event=self.event,
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('refund_states', [])
|
||||
order__event=self.event
|
||||
).order_by('created')
|
||||
|
||||
if form_data['successful_only']:
|
||||
payments = payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
)
|
||||
refunds = refunds.filter(
|
||||
state=OrderRefund.REFUND_STATE_DONE,
|
||||
)
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
if isinstance(obj, OrderPayment) and obj.payment_date:
|
||||
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
|
||||
elif isinstance(obj, OrderRefund) and obj.execution_date:
|
||||
@@ -552,7 +480,6 @@ class PaymentListExporter(ListExporter):
|
||||
else:
|
||||
d2 = ''
|
||||
row = [
|
||||
obj.order.event.slug,
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
@@ -565,10 +492,7 @@ class PaymentListExporter(ListExporter):
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_payments'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
|
||||
|
||||
class QuotaListExporter(ListExporter):
|
||||
@@ -578,26 +502,20 @@ class QuotaListExporter(ListExporter):
|
||||
def iterate_list(self, form_data):
|
||||
headers = [
|
||||
_('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'),
|
||||
_('Current user\'s carts'), _('Waiting list'), _('Exited orders'), _('Current availability')
|
||||
_('Current user\'s carts'), _('Waiting list'), _('Current availability')
|
||||
]
|
||||
yield headers
|
||||
|
||||
quotas = list(self.event.quotas.all())
|
||||
qa = QuotaAvailability(full_results=True)
|
||||
qa.queue(*quotas)
|
||||
qa.compute()
|
||||
|
||||
for quota in quotas:
|
||||
avail = qa.results[quota]
|
||||
for quota in self.event.quotas.all():
|
||||
avail = quota.availability()
|
||||
row = [
|
||||
quota.name,
|
||||
_('Infinite') if quota.size is None else quota.size,
|
||||
qa.count_paid_orders[quota],
|
||||
qa.count_pending_orders[quota],
|
||||
qa.count_vouchers[quota],
|
||||
qa.count_cart[quota],
|
||||
qa.count_waitinglist[quota],
|
||||
qa.count_exited_orders[quota],
|
||||
quota.count_paid_orders(),
|
||||
quota.count_pending_orders(),
|
||||
quota.count_blocking_vouchers(),
|
||||
quota.count_in_cart(),
|
||||
quota.count_waiting_list_pending(),
|
||||
_('Infinite') if avail[1] is None else avail[1]
|
||||
]
|
||||
yield row
|
||||
@@ -650,21 +568,10 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
qs = Invoice.objects.filter(event__in=self.events).order_by('full_invoice_no').select_related(
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).prefetch_related('order__payments').annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
@@ -721,11 +628,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
pmi,
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
])
|
||||
pmi
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
@@ -761,21 +664,9 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Payment providers'),
|
||||
]
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('invoice__order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('provider', delimiter=',')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
qs = InvoiceLine.objects.annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).filter(
|
||||
invoice__event__in=self.events
|
||||
qs = InvoiceLine.objects.filter(
|
||||
invoice__event=self.event
|
||||
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||
'invoice', 'invoice__order', 'invoice__refers'
|
||||
)
|
||||
@@ -813,21 +704,10 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((l.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
])
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def providers(self):
|
||||
return dict(get_all_payment_providers())
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_invoices'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
class GiftcardRedemptionListExporter(ListExporter):
|
||||
@@ -835,27 +715,27 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
verbose_name = gettext_lazy('Gift card redemptions')
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event__in=self.events,
|
||||
order__event=self.event,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event__in=self.events,
|
||||
order__event=self.event,
|
||||
provider='giftcard'
|
||||
).order_by('created')
|
||||
|
||||
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))
|
||||
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
|
||||
_('Order'), _('Payment ID'), _('Date'), _('Gift card code'), _('Amount'), _('Issuer')
|
||||
]
|
||||
yield headers
|
||||
|
||||
for obj in objs:
|
||||
tz = pytz.timezone(obj.order.event.settings.timezone)
|
||||
gc = GiftCard.objects.get(pk=obj.info_data.get('gift_card'))
|
||||
row = [
|
||||
obj.order.event.slug,
|
||||
obj.order.code,
|
||||
obj.full_id,
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
@@ -866,10 +746,7 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_giftcardredemptions'.format(self.events.first().organizer.slug)
|
||||
else:
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
@@ -877,21 +754,11 @@ def register_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_orderlist")
|
||||
def register_multievent_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
|
||||
def register_paymentlist_exporter(sender, **kwargs):
|
||||
return PaymentListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_paymentlist")
|
||||
def register_multievent_paymentlist_exporter(sender, **kwargs):
|
||||
return PaymentListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||
def register_quotalist_exporter(sender, **kwargs):
|
||||
return QuotaListExporter
|
||||
@@ -902,16 +769,6 @@ def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_invoicedata")
|
||||
def register_multievent_invoicedatae_xporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_giftcardredemptionlist")
|
||||
def register_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
return GiftcardRedemptionListExporter
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_giftcardredemptionlist")
|
||||
def register_multievent_i_giftcardredemptionlist_exporter(sender, **kwargs):
|
||||
return GiftcardRedemptionListExporter
|
||||
|
||||
@@ -48,9 +48,6 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
SECRET_REDACTED = '*****'
|
||||
|
||||
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
auto_fields = []
|
||||
|
||||
@@ -65,8 +62,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
for fname in self.auto_fields:
|
||||
kwargs = DEFAULTS[fname].get('form_kwargs', {})
|
||||
if callable(kwargs):
|
||||
kwargs = kwargs()
|
||||
kwargs.setdefault('required', False)
|
||||
field = DEFAULTS[fname]['form_class'](
|
||||
**kwargs
|
||||
@@ -78,12 +73,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def save(self):
|
||||
for k, v in self.cleaned_data.items():
|
||||
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
|
||||
self.cleaned_data[k] = self.initial[k]
|
||||
return super().save()
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
from pretix.base.models import Event
|
||||
|
||||
@@ -122,32 +111,3 @@ class SafeSessionWizardView(SessionWizardView):
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SecretKeySettingsWidget(forms.TextInput):
|
||||
def __init__(self, attrs=None):
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
attrs.update({
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
})
|
||||
super().__init__(attrs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
if value:
|
||||
value = SECRET_REDACTED
|
||||
return super().get_context(name, value, attrs)
|
||||
|
||||
|
||||
class SecretKeySettingsField(forms.CharField):
|
||||
widget = SecretKeySettingsWidget
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
if data == SECRET_REDACTED:
|
||||
return False
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def run_validators(self, value):
|
||||
if value == SECRET_REDACTED:
|
||||
return
|
||||
return super().run_validators(value)
|
||||
|
||||
@@ -40,7 +40,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
@@ -205,13 +205,12 @@ def guess_country(event):
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
# TODO: does this actually work?
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale.upper() in valid_countries:
|
||||
if locale in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
return country
|
||||
|
||||
@@ -244,10 +243,8 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
add_fields['attendee_name_parts'] = NamePartsFormField(
|
||||
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
@@ -256,7 +253,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
add_fields['attendee_email'] = forms.EmailField(
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required and not self.all_optional,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
|
||||
@@ -267,15 +264,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
)
|
||||
)
|
||||
if item.admission and event.settings.attendee_company_asked:
|
||||
add_fields['company'] = forms.CharField(
|
||||
self.fields['company'] = forms.CharField(
|
||||
required=event.settings.attendee_company_required and not self.all_optional,
|
||||
label=_('Company'),
|
||||
max_length=255,
|
||||
initial=(cartpos.company if cartpos else orderpos.company),
|
||||
)
|
||||
|
||||
if item.admission and event.settings.attendee_addresses_asked:
|
||||
add_fields['street'] = forms.CharField(
|
||||
self.fields['street'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('Address'),
|
||||
widget=forms.Textarea(attrs={
|
||||
@@ -285,26 +281,24 @@ class BaseQuestionsForm(forms.Form):
|
||||
}),
|
||||
initial=(cartpos.street if cartpos else orderpos.street),
|
||||
)
|
||||
add_fields['zipcode'] = forms.CharField(
|
||||
self.fields['zipcode'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
max_length=30,
|
||||
label=_('ZIP code'),
|
||||
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'postal-code',
|
||||
}),
|
||||
)
|
||||
add_fields['city'] = forms.CharField(
|
||||
self.fields['city'] = forms.CharField(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
label=_('City'),
|
||||
max_length=255,
|
||||
initial=(cartpos.city if cartpos else orderpos.city),
|
||||
widget=forms.TextInput(attrs={
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
|
||||
add_fields['country'] = CountryField(
|
||||
self.fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
required=event.settings.attendee_addresses_required and not self.all_optional,
|
||||
@@ -329,7 +323,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'state']
|
||||
|
||||
add_fields['state'] = forms.ChoiceField(
|
||||
self.fields['state'] = forms.ChoiceField(
|
||||
label=pgettext_lazy('address', 'State'),
|
||||
required=False,
|
||||
choices=c,
|
||||
@@ -337,14 +331,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
'autocomplete': 'address-level1',
|
||||
}),
|
||||
)
|
||||
add_fields['state'].widget.is_required = True
|
||||
|
||||
field_positions = list(
|
||||
[
|
||||
(n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0))
|
||||
for n in add_fields.keys()
|
||||
]
|
||||
)
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
@@ -425,17 +412,11 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = ExtFileField(
|
||||
field = forms.FileField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
ext_whitelist=(
|
||||
".png", ".jpg", ".gif", ".jpeg", ".pdf", ".txt", ".docx", ".gif", ".svg",
|
||||
".pptx", ".ppt", ".doc", ".xlsx", ".xls", ".jfif", ".heic", ".heif", ".pages",
|
||||
".bmp", ".tif", ".tiff"
|
||||
),
|
||||
max_size=10 * 1024 * 1024,
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
@@ -497,12 +478,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
field._required = q.required and not self.all_optional
|
||||
field.required = False
|
||||
|
||||
add_fields['question_%s' % q.id] = field
|
||||
field_positions.append(('question_%s' % q.id, q.position))
|
||||
|
||||
field_positions.sort(key=lambda e: e[1])
|
||||
for fname, p in field_positions:
|
||||
self.fields[fname] = add_fields[fname]
|
||||
self.fields['question_%s' % q.id] = field
|
||||
|
||||
responses = question_form_fields.send(sender=event, position=pos)
|
||||
data = pos.meta_info_data
|
||||
@@ -583,7 +559,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
'data-display-dependency': '#id_is_business_1',
|
||||
'autocomplete': 'organization',
|
||||
}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
labels = {
|
||||
@@ -633,11 +609,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
)
|
||||
self.fields['state'].widget.is_required = True
|
||||
|
||||
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
|
||||
if cc and cc not in EU_COUNTRIES and fprefix + 'vat_id' in self.data:
|
||||
self.data = self.data.copy()
|
||||
del self.data[fprefix + 'vat_id']
|
||||
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
@@ -652,6 +623,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['company'].widget.is_required = True
|
||||
self.fields['company'].widget.attrs['required'] = 'required'
|
||||
del self.fields['company'].widget.attrs['data-display-dependency']
|
||||
if 'vat_id' in self.fields:
|
||||
del self.fields['vat_id'].widget.attrs['data-display-dependency']
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
@@ -683,9 +656,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
data = self.cleaned_data
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
data['vat_id'] = ''
|
||||
if data.get('is_business') and not data.get('country') in EU_COUNTRIES:
|
||||
data['vat_id'] = ''
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
raise ValidationError(_('You need to provide a company name.'))
|
||||
@@ -706,6 +676,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
) and len(data.get('name_parts', {})) == 1:
|
||||
# Do not save the country if it is the only field set -- we don't know the user even checked it!
|
||||
self.cleaned_data['country'] = ''
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
|
||||
@@ -15,7 +15,6 @@ class DatePickerWidget(forms.DateInput):
|
||||
date_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control')
|
||||
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(
|
||||
@@ -33,7 +32,6 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs = dict(attrs)
|
||||
time_attrs.setdefault('class', 'form-control')
|
||||
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(
|
||||
@@ -104,8 +102,6 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
date_attrs['autocomplete'] = 'date-picker-do-not-autofill'
|
||||
time_attrs['autocomplete'] = 'time-picker-do-not-autofill'
|
||||
|
||||
def date_placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
|
||||
@@ -391,7 +391,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
|
||||
if not self.invoice.event.has_subevents:
|
||||
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' +
|
||||
@@ -672,7 +672,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
table
|
||||
]))
|
||||
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||||
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
story.append(Paragraph(
|
||||
pgettext(
|
||||
@@ -680,7 +679,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
'{date}, the invoice total corresponds to {total}.'
|
||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
||||
total=fmt(foreign_total)),
|
||||
total=fmt(total)),
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
|
||||
|
||||
@@ -212,9 +212,8 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||
'report-uri': ["/csp_report/"],
|
||||
}
|
||||
if settings.LOG_CSP:
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
if 'Content-Security-Policy' in resp:
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
@@ -274,7 +274,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organizer',
|
||||
|
||||
@@ -65,7 +65,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
@@ -229,7 +229,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('name',),
|
||||
|
||||
@@ -182,12 +182,12 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
|
||||
@@ -23,12 +23,12 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
|
||||
@@ -124,7 +124,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
@@ -179,7 +179,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=merge_names,
|
||||
|
||||
@@ -342,7 +342,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True,
|
||||
field=models.SlugField(
|
||||
help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily 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=[django.core.validators.RegexValidator(
|
||||
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
|
||||
|
||||
@@ -33,7 +33,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
|
||||
@@ -36,7 +36,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(merge_names, migrations.RunPython.noop)
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily 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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily 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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoice',
|
||||
|
||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily 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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily 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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventmetaproperty',
|
||||
@@ -54,7 +54,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='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=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CheckinList',
|
||||
|
||||
@@ -97,7 +97,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True,
|
||||
field=models.SlugField(
|
||||
help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be '
|
||||
'unique among your events. We recommend some kind of abbreviation or a date with less than '
|
||||
'10 characters that can be easily remembered, but you can also choose to use a random '
|
||||
@@ -119,7 +119,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True,
|
||||
field=models.SlugField(
|
||||
help_text='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.',
|
||||
|
||||
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='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.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='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.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsession',
|
||||
|
||||
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.CharField(max_length=50, db_index=True, help_text='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.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
field=models.SlugField(help_text='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.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBanlistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='staffsession',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-11 15:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_countries.fields
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0151_auto_20200421_0737'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='device',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='checkins', to='pretixbase.Device'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='forced',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='type',
|
||||
field=models.CharField(default='entry', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkinlist',
|
||||
name='allow_entry_after_exit',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkinlist',
|
||||
name='allow_multiple_entries',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkinlist',
|
||||
name='rules',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='checkin',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-28 19:53
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0152_auto_20200511_1504'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='x',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seat',
|
||||
name='y',
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='seat',
|
||||
name='seat_guid',
|
||||
field=models.CharField(db_index=True, max_length=190),
|
||||
),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-20 16:33
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0153_auto_20200528_1953'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subeventitem',
|
||||
name='disabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subeventitemvariation',
|
||||
name='disabled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceaddress',
|
||||
name='country',
|
||||
field=django_countries.fields.CountryField(countries=pretix.helpers.countries.CachedCountries, max_length=2),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-26 14:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0154_auto_20200620_1633'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quota',
|
||||
name='release_after_exit',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-28 19:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0155_quota_release_after_exit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='override_tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,8 @@
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
@@ -20,15 +19,6 @@ class CheckinList(LoggedModel):
|
||||
default=False,
|
||||
help_text=_('With this option, people will be able to check in even if the '
|
||||
'order have not been paid.'))
|
||||
allow_entry_after_exit = models.BooleanField(
|
||||
verbose_name=_('Allow re-entering after an exit scan'),
|
||||
default=True
|
||||
)
|
||||
allow_multiple_entries = models.BooleanField(
|
||||
verbose_name=_('Allow multiple entries per ticket'),
|
||||
help_text=_('Use this option to turn off warnings if a ticket is scanned a second time.'),
|
||||
default=False
|
||||
)
|
||||
|
||||
auto_checkin_sales_channels = MultiStringField(
|
||||
default=[],
|
||||
@@ -38,7 +28,6 @@ class CheckinList(LoggedModel):
|
||||
'any of the selected sales channels. This option can be useful when tickets sold at the box office '
|
||||
'are not checked again before entry and should be considered validated directly upon purchase.')
|
||||
)
|
||||
rules = FallbackJSONField(default=dict, blank=True)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@@ -51,43 +40,13 @@ class CheckinList(LoggedModel):
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [
|
||||
Order.STATUS_PAID],
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
|
||||
subevent=self.subevent
|
||||
)
|
||||
if self.subevent_id:
|
||||
qs = qs.filter(subevent_id=self.subevent_id)
|
||||
if not self.all_products:
|
||||
qs = qs.filter(item__in=self.limit_products.values_list('id', flat=True))
|
||||
return qs
|
||||
|
||||
@property
|
||||
def inside_count(self):
|
||||
return self.positions.annotate(
|
||||
last_entry=Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_ENTRY,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
last_exit=Subquery(
|
||||
Checkin.objects.filter(
|
||||
position_id=OuterRef('pk'),
|
||||
list_id=self.pk,
|
||||
type=Checkin.TYPE_EXIT,
|
||||
).order_by().values('position_id').annotate(
|
||||
m=Max('datetime')
|
||||
).values('m')
|
||||
),
|
||||
).filter(
|
||||
Q(last_entry__isnull=False)
|
||||
& Q(
|
||||
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
|
||||
)
|
||||
).count()
|
||||
|
||||
@property
|
||||
def checkin_count(self):
|
||||
return self.event.cache.get_or_set(
|
||||
@@ -129,31 +88,20 @@ class CheckinList(LoggedModel):
|
||||
|
||||
class Checkin(models.Model):
|
||||
"""
|
||||
A check-in object is created when a person enters or exits the event.
|
||||
A check-in object is created when a person enters the event.
|
||||
"""
|
||||
TYPE_ENTRY = 'entry'
|
||||
TYPE_EXIT = 'exit'
|
||||
CHECKIN_TYPES = (
|
||||
(TYPE_ENTRY, _('Entry')),
|
||||
(TYPE_EXIT, _('Exit')),
|
||||
)
|
||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE)
|
||||
datetime = models.DateTimeField(default=now)
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
list = models.ForeignKey(
|
||||
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
|
||||
)
|
||||
type = models.CharField(max_length=100, choices=CHECKIN_TYPES, default=TYPE_ENTRY)
|
||||
forced = models.BooleanField(default=False)
|
||||
device = models.ForeignKey(
|
||||
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
auto_checked_in = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='position__order__event__organizer')
|
||||
|
||||
class Meta:
|
||||
ordering = (('-datetime'),)
|
||||
unique_together = (('list', 'position'),)
|
||||
|
||||
def __repr__(self):
|
||||
return "<Checkin: pos {} on list '{}' at {}>".format(
|
||||
@@ -161,12 +109,12 @@ class Checkin(models.Model):
|
||||
)
|
||||
|
||||
def save(self, **kwargs):
|
||||
super().save(**kwargs)
|
||||
self.position.order.touch()
|
||||
self.list.event.cache.delete('checkin_count')
|
||||
self.list.touch()
|
||||
super().save(**kwargs)
|
||||
|
||||
def delete(self, **kwargs):
|
||||
super().delete(**kwargs)
|
||||
self.position.order.touch()
|
||||
super().delete(**kwargs)
|
||||
self.list.touch()
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.formats import date_format
|
||||
@@ -214,10 +214,8 @@ class EventMixin:
|
||||
vars_reserved = set()
|
||||
items_gone = set()
|
||||
vars_gone = set()
|
||||
|
||||
r = getattr(self, '_quota_cache', {})
|
||||
for q in self.active_quotas:
|
||||
res = r[q] if q in r else q.availability(allow_cache=True)
|
||||
res = q.availability(allow_cache=True)
|
||||
|
||||
if res[0] == Quota.AVAILABILITY_OK:
|
||||
if q.active_items:
|
||||
@@ -287,7 +285,7 @@ class Event(EventMixin, LoggedModel):
|
||||
max_length=200,
|
||||
verbose_name=_("Event name"),
|
||||
)
|
||||
slug = models.CharField(
|
||||
slug = models.SlugField(
|
||||
max_length=50, db_index=True,
|
||||
help_text=_(
|
||||
"Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your "
|
||||
@@ -375,7 +373,6 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.invoice_include_expire_date = True
|
||||
self.settings.ticketoutput_pdf__enabled = True
|
||||
self.settings.ticketoutput_passbook__enabled = True
|
||||
self.settings.event_list_type = 'calendar'
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
@@ -392,17 +389,36 @@ class Event(EventMixin, LoggedModel):
|
||||
return urljoin(build_absolute_uri(self, 'presale:event.index'), img)
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .seating import Seat
|
||||
|
||||
qs_annotated = Seat.annotated(self.seats, self.pk, None,
|
||||
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
|
||||
minimal_distance=self.settings.seating_minimal_distance,
|
||||
distance_only_within_row=self.settings.seating_distance_within_row)
|
||||
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
),
|
||||
has_cart=Exists(
|
||||
CartPosition.objects.filter(
|
||||
event=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
@@ -609,29 +625,12 @@ class Event(EventMixin, LoggedModel):
|
||||
q.dependency_question = question_map[q.dependency_question_id]
|
||||
q.save(update_fields=['dependency_question'])
|
||||
|
||||
def _walk_rules(rules):
|
||||
if isinstance(rules, dict):
|
||||
for k, v in rules.items():
|
||||
if k == 'lookup':
|
||||
if v[0] == 'product':
|
||||
v[1] = str(item_map.get(int(v[1]), 0).pk)
|
||||
elif v[0] == 'variation':
|
||||
v[1] = str(variation_map.get(int(v[1]), 0).pk)
|
||||
else:
|
||||
_walk_rules(v)
|
||||
elif isinstance(rules, list):
|
||||
for i in rules:
|
||||
_walk_rules(i)
|
||||
|
||||
checkin_list_map = {}
|
||||
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
|
||||
items = list(cl.limit_products.all())
|
||||
checkin_list_map[cl.pk] = cl
|
||||
cl.pk = None
|
||||
cl.event = self
|
||||
rules = cl.rules
|
||||
_walk_rules(rules)
|
||||
cl.rules = rules
|
||||
cl.save()
|
||||
cl.log_action('pretix.object.cloned')
|
||||
for i in items:
|
||||
@@ -1043,15 +1042,39 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
).strip()
|
||||
|
||||
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
|
||||
from .seating import Seat
|
||||
qs_annotated = Seat.annotated(self.seats, self.event_id, self,
|
||||
ignore_voucher_id=ignore_voucher.pk if ignore_voucher else None,
|
||||
minimal_distance=self.settings.seating_minimal_distance,
|
||||
distance_only_within_row=self.settings.seating_distance_within_row)
|
||||
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if self.settings.seating_minimal_distance > 0:
|
||||
qs = qs.filter(has_closeby_taken=False)
|
||||
|
||||
from .orders import CartPosition, Order, OrderPosition
|
||||
from .vouchers import Voucher
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher:
|
||||
vqs = vqs.exclude(pk=ignore_voucher.pk)
|
||||
qs = self.seats.annotate(
|
||||
has_order=Exists(
|
||||
OrderPosition.objects.filter(
|
||||
order__event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
),
|
||||
has_cart=Exists(
|
||||
CartPosition.objects.filter(
|
||||
event_id=self.event_id,
|
||||
subevent=self,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
).filter(has_order=False, has_cart=False, has_voucher=False)
|
||||
if not (sales_channel in self.settings.seating_allow_blocked_seats_for_channel or include_blocked):
|
||||
qs = qs.filter(blocked=False)
|
||||
return qs
|
||||
@@ -1061,35 +1084,21 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
return self.event.settings
|
||||
|
||||
@cached_property
|
||||
def item_overrides(self):
|
||||
def item_price_overrides(self):
|
||||
from .items import SubEventItem
|
||||
|
||||
return {
|
||||
si.item_id: si
|
||||
for si in SubEventItem.objects.filter(subevent=self)
|
||||
si.item_id: si.price
|
||||
for si in SubEventItem.objects.filter(subevent=self, price__isnull=False)
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def var_overrides(self):
|
||||
def var_price_overrides(self):
|
||||
from .items import SubEventItemVariation
|
||||
|
||||
return {
|
||||
si.variation_id: si
|
||||
for si in SubEventItemVariation.objects.filter(subevent=self)
|
||||
}
|
||||
|
||||
@property
|
||||
def item_price_overrides(self):
|
||||
return {
|
||||
si.item_id: si.price
|
||||
for si in self.item_overrides.values() if si.price is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def var_price_overrides(self):
|
||||
return {
|
||||
si.variation_id: si.price
|
||||
for si in self.var_overrides.values() if si.price is not None
|
||||
for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False)
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -1106,15 +1115,13 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
return not self.orderposition_set.exists()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
clear_cache = kwargs.pop('clear_cache', False)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.event and clear_cache:
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
clear_cache = kwargs.pop('clear_cache', False)
|
||||
super().save(*args, **kwargs)
|
||||
if self.event and clear_cache:
|
||||
if self.event:
|
||||
self.event.cache.clear()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -9,10 +9,10 @@ from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import pgettext
|
||||
from django_countries.fields import CountryField
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -84,7 +84,7 @@ class Invoice(models.Model):
|
||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_from_city = models.CharField(max_length=190, null=True)
|
||||
invoice_from_country = FastCountryField(null=True)
|
||||
invoice_from_country = CountryField(null=True)
|
||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
invoice_to = models.TextField()
|
||||
@@ -94,7 +94,7 @@ class Invoice(models.Model):
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_state = models.CharField(max_length=190, null=True)
|
||||
invoice_to_country = FastCountryField(null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
|
||||
@@ -7,10 +7,11 @@ from typing import Tuple
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils import formats
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -24,6 +25,7 @@ from pretix.base.models import fields
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.signals import quota_availability
|
||||
|
||||
from .event import Event, SubEvent
|
||||
|
||||
@@ -118,7 +120,6 @@ class SubEventItem(models.Model):
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
item = models.ForeignKey('Item', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
disabled = models.BooleanField(default=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -146,7 +147,6 @@ class SubEventItemVariation(models.Model):
|
||||
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
|
||||
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
|
||||
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
|
||||
disabled = models.BooleanField(default=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
@@ -448,32 +448,24 @@ class Item(LoggedModel):
|
||||
return self.event.settings.show_quota_left
|
||||
return self.show_quota_left
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
price = price if price is not None else self.default_price
|
||||
|
||||
if not self.tax_rule:
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
|
||||
override_tax_rate=override_tax_rate, currency=currency or self.event.currency)
|
||||
t = self.tax_rule.tax(price, base_price_is=base_price_is,
|
||||
currency=currency or self.event.currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count,
|
||||
invoice_address=invoice_address,
|
||||
base_price_is='gross',
|
||||
currency=currency)
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count,
|
||||
override_tax_rate=override_tax_rate,
|
||||
invoice_address=invoice_address,
|
||||
currency=currency)
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
@@ -543,8 +535,6 @@ class Item(LoggedModel):
|
||||
quotacounter[q] += b.count
|
||||
|
||||
for q, n in quotacounter.items():
|
||||
if n == 0:
|
||||
continue
|
||||
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
|
||||
if a[1] is None:
|
||||
continue
|
||||
@@ -681,31 +671,23 @@ class ItemVariation(models.Model):
|
||||
def price(self):
|
||||
return self.default_price if self.default_price is not None else self.item.default_price
|
||||
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False, override_tax_rate=None,
|
||||
invoice_address=None):
|
||||
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
|
||||
price = price if price is not None else self.price
|
||||
|
||||
if not self.item.tax_rule:
|
||||
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
|
||||
rate=Decimal('0.00'), name='')
|
||||
else:
|
||||
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency,
|
||||
override_tax_rate=override_tax_rate,
|
||||
invoice_address=invoice_address)
|
||||
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
|
||||
|
||||
if include_bundled:
|
||||
for b in self.item.bundles.all():
|
||||
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
|
||||
if b.bundled_variation:
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency,
|
||||
invoice_address=invoice_address)
|
||||
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
else:
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency,
|
||||
invoice_address=invoice_address)
|
||||
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross',
|
||||
currency=currency, invoice_address=invoice_address)
|
||||
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
|
||||
t.net += bprice.net - compare_price.net
|
||||
t.tax += bprice.tax - compare_price.tax
|
||||
t.name = "MIXED!"
|
||||
@@ -1351,16 +1333,6 @@ class Quota(LoggedModel):
|
||||
)
|
||||
closed = models.BooleanField(default=False)
|
||||
|
||||
release_after_exit = models.BooleanField(
|
||||
verbose_name=_('Allow to sell more tickets once people have checked out'),
|
||||
help_text=_('With this option, quota will be released as soon as people are scanned at an exit of your event. '
|
||||
'This will only happen if they have been scanned both at an entry and at an exit and the exit '
|
||||
'is the more recent scan. It does not matter which check-in list either of the scans was on, '
|
||||
'but check-in lists are ignored if they are set to "Allow re-entering after an exit scan" to '
|
||||
'prevent accidental overbooking.'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
@@ -1378,7 +1350,6 @@ class Quota(LoggedModel):
|
||||
self.event.cache.clear()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# This is *not* called when the db-level cache is upated, since we use bulk_update there
|
||||
clear_cache = kwargs.pop('clear_cache', True)
|
||||
super().save(*args, **kwargs)
|
||||
if self.event and clear_cache:
|
||||
@@ -1413,8 +1384,6 @@ class Quota(LoggedModel):
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
from ..services.quotas import QuotaAvailability
|
||||
|
||||
if allow_cache and self.cache_is_hot() and count_waitinglist:
|
||||
return self.cached_availability_state, self.cached_availability_number
|
||||
|
||||
@@ -1423,16 +1392,141 @@ class Quota(LoggedModel):
|
||||
|
||||
if _cache is not None and self.pk in _cache:
|
||||
return _cache[self.pk]
|
||||
qa = QuotaAvailability(count_waitinglist=count_waitinglist, early_out=False)
|
||||
qa.queue(self)
|
||||
qa.compute(now_dt=now_dt)
|
||||
res = qa.results[self]
|
||||
now_dt = now_dt or now()
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
for recv, resp in quota_availability.send(sender=self.event, quota=self, result=res,
|
||||
count_waitinglist=count_waitinglist):
|
||||
res = resp
|
||||
|
||||
if res[0] <= Quota.AVAILABILITY_ORDERED and self.close_when_sold_out and not self.closed:
|
||||
self.closed = True
|
||||
self.save(update_fields=['closed'])
|
||||
self.log_action('pretix.event.quota.closed')
|
||||
|
||||
self.event.cache.delete('item_quota_cache')
|
||||
rewrite_cache = count_waitinglist and (
|
||||
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||
)
|
||||
if rewrite_cache:
|
||||
self.cached_availability_state = res[0]
|
||||
self.cached_availability_number = res[1]
|
||||
self.cached_availability_time = now_dt
|
||||
if self.size is None:
|
||||
self.cached_availability_paid_orders = self.count_paid_orders()
|
||||
self.save(
|
||||
update_fields=[
|
||||
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
|
||||
'cached_availability_paid_orders'
|
||||
],
|
||||
clear_cache=False,
|
||||
using='default'
|
||||
)
|
||||
|
||||
if _cache is not None:
|
||||
_cache[self.pk] = res
|
||||
_cache['_count_waitinglist'] = count_waitinglist
|
||||
return res
|
||||
|
||||
def _availability(self, now_dt: datetime=None, count_waitinglist=True, ignore_closed=False):
|
||||
now_dt = now_dt or now()
|
||||
if self.closed and not ignore_closed:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left = self.size
|
||||
if size_left is None:
|
||||
return Quota.AVAILABILITY_OK, None
|
||||
|
||||
paid_orders = self.count_paid_orders()
|
||||
self.cached_availability_paid_orders = paid_orders
|
||||
size_left -= paid_orders
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_GONE, 0
|
||||
|
||||
size_left -= self.count_pending_orders()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
def count_blocking_vouchers(self, now_dt: datetime=None) -> int:
|
||||
from pretix.base.models import Voucher
|
||||
|
||||
now_dt = now_dt or now()
|
||||
if 'sqlite3' in settings.DATABASES['default']['ENGINE']:
|
||||
func = 'MAX'
|
||||
else: # NOQA
|
||||
func = 'GREATEST'
|
||||
|
||||
return Voucher.objects.filter(
|
||||
Q(event=self.event) & Q(subevent=self.subevent) &
|
||||
Q(block_quota=True) &
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
|
||||
Q(Q(self._position_lookup) | Q(quota=self))
|
||||
).values('id').aggregate(
|
||||
free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func))
|
||||
)['free'] or 0
|
||||
|
||||
def count_waiting_list_pending(self) -> int:
|
||||
from pretix.base.models import WaitingListEntry
|
||||
return WaitingListEntry.objects.filter(
|
||||
Q(voucher__isnull=True) & Q(subevent=self.subevent) &
|
||||
self._position_lookup
|
||||
).distinct().count()
|
||||
|
||||
def count_in_cart(self, now_dt: datetime=None) -> int:
|
||||
from pretix.base.models import CartPosition
|
||||
|
||||
now_dt = now_dt or now()
|
||||
return CartPosition.objects.filter(
|
||||
Q(event=self.event) & Q(subevent=self.subevent) &
|
||||
Q(expires__gte=now_dt) &
|
||||
Q(
|
||||
Q(voucher__isnull=True)
|
||||
| Q(voucher__block_quota=False)
|
||||
| Q(voucher__valid_until__lt=now_dt)
|
||||
) &
|
||||
self._position_lookup
|
||||
).count()
|
||||
|
||||
def count_pending_orders(self) -> dict:
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event, subevent=self.subevent
|
||||
).count()
|
||||
|
||||
def count_paid_orders(self):
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event, subevent=self.subevent
|
||||
).count()
|
||||
|
||||
@cached_property
|
||||
def _position_lookup(self) -> Q:
|
||||
return (
|
||||
( # Orders for items which do not have any variations
|
||||
Q(variation__isnull=True) &
|
||||
Q(item_id__in=Quota.items.through.objects.filter(quota_id=self.pk).values_list('item_id', flat=True))
|
||||
) | ( # Orders for items which do have any variations
|
||||
Q(variation__in=Quota.variations.through.objects.filter(quota_id=self.pk).values_list('itemvariation_id', flat=True))
|
||||
)
|
||||
)
|
||||
|
||||
class QuotaExceededException(Exception):
|
||||
pass
|
||||
|
||||
@@ -1441,6 +1535,7 @@ class Quota(LoggedModel):
|
||||
for variation in (variations or []):
|
||||
if variation.item not in items:
|
||||
raise ValidationError(_('All variations must belong to an item contained in the items list.'))
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def clean_items(event, items, variations):
|
||||
|
||||
@@ -22,7 +22,7 @@ class LogEntry(models.Model):
|
||||
in the database. This uses django.contrib.contenttypes to allow a
|
||||
relation to an arbitrary database object.
|
||||
|
||||
:param datetime: The timestamp of the logged action
|
||||
:param datatime: The timestamp of the logged action
|
||||
:type datetime: datetime
|
||||
:param user: The user that performed the action
|
||||
:type user: User
|
||||
|
||||
@@ -27,7 +27,7 @@ from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from django_countries.fields import Country, CountryField
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
@@ -44,7 +44,7 @@ from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ...helpers.countries import CachedCountries
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
@@ -502,16 +502,10 @@ class Order(LockModel, LoggedModel):
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||
for p in payments:
|
||||
if p.payment_provider:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
else:
|
||||
p.full_refund_possible = False
|
||||
p.partial_refund_possible = False
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = Decimal('0.00')
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
to_refund = amount
|
||||
@@ -795,7 +789,7 @@ class Order(LockModel, LoggedModel):
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail, TolerantDict
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
if not self.email:
|
||||
return
|
||||
@@ -810,7 +804,6 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
try:
|
||||
email_content = render_mail(template, context)
|
||||
subject = subject.format_map(TolerantDict(context))
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
@@ -1077,7 +1070,7 @@ class AbstractPosition(models.Model):
|
||||
street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True, null=True)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True, null=True)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
country = CountryField(verbose_name=_('Country'), blank=True, blank_label=_('Select country'), null=True)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -1172,33 +1165,6 @@ class AbstractPosition(models.Model):
|
||||
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
@property
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
if sd:
|
||||
return sd.name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
def state_for_address(self):
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
return ""
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
|
||||
return self.state_name
|
||||
return self.state
|
||||
|
||||
def address_format(self):
|
||||
lines = [
|
||||
self.attendee_name,
|
||||
self.company,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name
|
||||
]
|
||||
lines = [r.strip() for r in lines if r]
|
||||
return '\n'.join(lines).strip()
|
||||
|
||||
|
||||
class OrderPayment(models.Model):
|
||||
"""
|
||||
@@ -1801,10 +1767,7 @@ class OrderFee(models.Model):
|
||||
self.fee_type, self.value
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
if tax_rule:
|
||||
self.tax_rule = tax_rule
|
||||
|
||||
def _calculate_tax(self):
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -1814,9 +1777,13 @@ class OrderFee(models.Model):
|
||||
self.tax_rule = self.order.event.settings.tax_rate_default
|
||||
|
||||
if self.tax_rule:
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia)
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
if self.tax_rule.tax_applicable(ia):
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
@@ -1956,16 +1923,20 @@ class OrderPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
self.tax_rule = tax_rule or self.item.tax_rule
|
||||
def _calculate_tax(self):
|
||||
self.tax_rule = self.item.tax_rule
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
if self.tax_rule:
|
||||
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
if self.tax_rule.tax_applicable(ia):
|
||||
tax = self.tax_rule.tax(self.price, base_price_is='gross')
|
||||
self.tax_rate = tax.rate
|
||||
self.tax_value = tax.tax
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
else:
|
||||
self.tax_value = Decimal('0.00')
|
||||
self.tax_rate = Decimal('0.00')
|
||||
@@ -2104,10 +2075,6 @@ class CartPosition(AbstractPosition):
|
||||
includes_tax = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
override_tax_rate = models.DecimalField(
|
||||
max_digits=10, decimal_places=2,
|
||||
null=True, blank=True
|
||||
)
|
||||
is_bundled = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
@@ -2124,8 +2091,6 @@ class CartPosition(AbstractPosition):
|
||||
@property
|
||||
def tax_rate(self):
|
||||
if self.includes_tax:
|
||||
if self.override_tax_rate is not None:
|
||||
return self.override_tax_rate
|
||||
return self.item.tax(self.price, base_price_is='gross').rate
|
||||
else:
|
||||
return Decimal('0.00')
|
||||
@@ -2133,7 +2098,7 @@ class CartPosition(AbstractPosition):
|
||||
@property
|
||||
def tax_value(self):
|
||||
if self.includes_tax:
|
||||
return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax
|
||||
return self.item.tax(self.price, base_price_is='gross').tax
|
||||
else:
|
||||
return Decimal('0.00')
|
||||
|
||||
@@ -2149,8 +2114,8 @@ class InvoiceAddress(models.Model):
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
|
||||
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
|
||||
countries=CachedCountries)
|
||||
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
|
||||
countries=CachedCountries)
|
||||
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
|
||||
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
|
||||
help_text=_('Only for business customers within the EU.'))
|
||||
|
||||
@@ -32,7 +32,7 @@ class Organizer(LoggedModel):
|
||||
settings_namespace = 'organizer'
|
||||
name = models.CharField(max_length=200,
|
||||
verbose_name=_("Name"))
|
||||
slug = models.CharField(
|
||||
slug = models.SlugField(
|
||||
max_length=50, db_index=True,
|
||||
help_text=_(
|
||||
"Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used "
|
||||
|
||||
@@ -5,8 +5,7 @@ import jsonschema
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
||||
from django.db.models.functions import Power
|
||||
from django.db.models import F, Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
@@ -42,7 +41,7 @@ class SeatingPlan(LoggedModel):
|
||||
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
|
||||
|
||||
Category = namedtuple('Categrory', 'name')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label x y')
|
||||
RawSeat = namedtuple('Seat', 'name guid number row category zone sorting_rank row_label seat_label')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -70,9 +69,7 @@ class SeatingPlan(LoggedModel):
|
||||
# *will* have gaps. We chose this way over just sorting the seats and continuously enumerating them as an
|
||||
# optimization, because this way we do not need to update the rank of very seat if we change a plan a little.
|
||||
for zi, z in enumerate(self.layout_data['zones']):
|
||||
zpos = (z['position']['x'], z['position']['y'])
|
||||
for ri, r in enumerate(z['rows']):
|
||||
rpos = (zpos[0] + r['position']['x'], zpos[1] + r['position']['y'])
|
||||
row_label = None
|
||||
if r.get('row_label'):
|
||||
row_label = r['row_label'].replace("%s", r.get('row_number', str(ri)))
|
||||
@@ -101,9 +98,7 @@ class SeatingPlan(LoggedModel):
|
||||
seat_label=seat_label,
|
||||
zone=z['name'],
|
||||
category=s['category'],
|
||||
sorting_rank=rank,
|
||||
x=rpos[0] + s['position']['x'],
|
||||
y=rpos[1] + s['position']['y'],
|
||||
sorting_rank=rank
|
||||
)
|
||||
|
||||
|
||||
@@ -135,8 +130,6 @@ class Seat(models.Model):
|
||||
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
|
||||
blocked = models.BooleanField(default=False)
|
||||
sorting_rank = models.BigIntegerField(default=0)
|
||||
x = models.FloatField(null=True)
|
||||
y = models.FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['sorting_rank', 'seat_guid']
|
||||
@@ -160,68 +153,7 @@ class Seat(models.Model):
|
||||
return self.name
|
||||
return ', '.join(parts)
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
|
||||
ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False):
|
||||
from . import Order, OrderPosition, Voucher, CartPosition
|
||||
|
||||
vqs = Voucher.objects.filter(
|
||||
event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
redeemed__lt=F('max_usages'),
|
||||
).filter(
|
||||
Q(valid_until__isnull=True) | Q(valid_until__gte=now())
|
||||
)
|
||||
if ignore_voucher_id:
|
||||
vqs = vqs.exclude(pk=ignore_voucher_id)
|
||||
opqs = OrderPosition.objects.filter(
|
||||
order__event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
|
||||
)
|
||||
if ignore_order_id:
|
||||
opqs = opqs.exclude(order_id=ignore_order_id)
|
||||
cqs = CartPosition.objects.filter(
|
||||
event_id=event_id,
|
||||
subevent=subevent,
|
||||
seat_id=OuterRef('pk'),
|
||||
expires__gte=now()
|
||||
)
|
||||
if ignore_cart_id:
|
||||
cqs = cqs.exclude(cart_id=ignore_cart_id)
|
||||
qs_annotated = qs.annotate(
|
||||
has_order=Exists(
|
||||
opqs
|
||||
),
|
||||
has_cart=Exists(
|
||||
cqs
|
||||
),
|
||||
has_voucher=Exists(
|
||||
vqs
|
||||
)
|
||||
)
|
||||
|
||||
if minimal_distance > 0:
|
||||
# TODO: Is there a more performant implementation on PostgreSQL using
|
||||
# https://www.postgresql.org/docs/8.2/functions-geometry.html ?
|
||||
sq_closeby = qs_annotated.annotate(
|
||||
distance=(
|
||||
Power(F('x') - OuterRef('x'), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - OuterRef('y'), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).filter(
|
||||
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
|
||||
distance__lt=minimal_distance ** 2
|
||||
)
|
||||
if distance_only_within_row:
|
||||
sq_closeby = sq_closeby.filter(row_name=OuterRef('row_name'))
|
||||
qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby))
|
||||
return qs_annotated
|
||||
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web',
|
||||
ignore_distancing=False, distance_ignore_cart_id=None):
|
||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web'):
|
||||
from .orders import Order
|
||||
|
||||
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
||||
@@ -241,32 +173,4 @@ class Seat(models.Model):
|
||||
opqs = opqs.exclude(pk=ignore_orderpos.pk)
|
||||
if ignore_voucher_id:
|
||||
vqs = vqs.exclude(pk=ignore_voucher_id)
|
||||
|
||||
if opqs.exists() or (ignore_cart is not True and cpqs.exists()) or vqs.exists():
|
||||
return False
|
||||
|
||||
if self.event.settings.seating_minimal_distance > 0 and not ignore_distancing:
|
||||
ev = (self.subevent or self.event)
|
||||
qs_annotated = Seat.annotated(ev.seats, self.event_id, self.subevent,
|
||||
ignore_voucher_id=ignore_voucher_id,
|
||||
minimal_distance=0,
|
||||
ignore_order_id=ignore_orderpos.order_id if ignore_orderpos else None,
|
||||
ignore_cart_id=(
|
||||
distance_ignore_cart_id or
|
||||
(ignore_cart.cart_id if ignore_cart else None)
|
||||
))
|
||||
qs_closeby_taken = qs_annotated.annotate(
|
||||
distance=(
|
||||
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).exclude(pk=self.pk).filter(
|
||||
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
|
||||
distance__lt=self.event.settings.seating_minimal_distance ** 2
|
||||
)
|
||||
if self.event.settings.seating_distance_within_row:
|
||||
qs_closeby_taken = qs_closeby_taken.filter(row_name=self.row_name)
|
||||
if qs_closeby_taken.exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
return not opqs.exists() and (ignore_cart is True or not cpqs.exists()) and not vqs.exists()
|
||||
|
||||
@@ -5,12 +5,12 @@ from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
|
||||
|
||||
class TaxedPrice:
|
||||
@@ -116,7 +116,7 @@ class TaxRule(LoggedModel):
|
||||
"ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax "
|
||||
"calculation. USE AT YOUR OWN RISK.")
|
||||
)
|
||||
home_country = FastCountryField(
|
||||
home_country = CountryField(
|
||||
verbose_name=_('Merchant country'),
|
||||
blank=True,
|
||||
help_text=_('Your country of residence. This is the country the EU reverse charge rule will not apply in, '
|
||||
@@ -164,39 +164,16 @@ class TaxRule(LoggedModel):
|
||||
def has_custom_rules(self):
|
||||
return self.custom_rules and self.custom_rules != '[]'
|
||||
|
||||
def tax_rate_for(self, invoice_address):
|
||||
if not self._tax_applicable(invoice_address):
|
||||
return Decimal('0.00')
|
||||
if self.has_custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
||||
return Decimal(rule.get('rate'))
|
||||
return Decimal(self.rate)
|
||||
|
||||
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
|
||||
subtract_from_gross=Decimal('0.00')):
|
||||
def tax(self, base_price, base_price_is='auto', currency=None):
|
||||
from .event import Event
|
||||
try:
|
||||
currency = currency or self.event.currency
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
rate = Decimal(self.rate)
|
||||
if override_tax_rate is not None:
|
||||
rate = override_tax_rate
|
||||
elif invoice_address:
|
||||
adjust_rate = self.tax_rate_for(invoice_address)
|
||||
if adjust_rate != rate:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.net
|
||||
base_price_is = 'net'
|
||||
subtract_from_gross = Decimal('0.00')
|
||||
rate = adjust_rate
|
||||
|
||||
if rate == Decimal('0.00'):
|
||||
if self.rate == Decimal('0.00'):
|
||||
return TaxedPrice(
|
||||
net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
|
||||
rate=rate, name=self.name
|
||||
net=base_price, gross=base_price, tax=Decimal('0.00'),
|
||||
rate=self.rate, name=self.name
|
||||
)
|
||||
|
||||
if base_price_is == 'auto':
|
||||
@@ -206,22 +183,19 @@ class TaxRule(LoggedModel):
|
||||
base_price_is = 'net'
|
||||
|
||||
if base_price_is == 'gross':
|
||||
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
|
||||
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
|
||||
gross = base_price
|
||||
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
|
||||
currency)
|
||||
elif base_price_is == 'net':
|
||||
net = base_price
|
||||
gross = round_decimal((net * (1 + rate / 100)), currency)
|
||||
if subtract_from_gross:
|
||||
gross -= subtract_from_gross
|
||||
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
|
||||
currency)
|
||||
gross = round_decimal((net * (1 + self.rate / 100)),
|
||||
currency)
|
||||
else:
|
||||
raise ValueError('Unknown base price type: {}'.format(base_price_is))
|
||||
|
||||
return TaxedPrice(
|
||||
net=net, gross=gross, tax=gross - net,
|
||||
rate=rate, name=self.name
|
||||
rate=self.rate, name=self.name
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -269,7 +243,7 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return False
|
||||
|
||||
def _tax_applicable(self, invoice_address):
|
||||
def tax_applicable(self, invoice_address):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.template.loader import get_template
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import Countries
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -33,7 +34,6 @@ from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.presale.views import get_cart, get_cart_total
|
||||
@@ -286,7 +286,7 @@ class BasePaymentProvider:
|
||||
('_restricted_countries',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Restrict to countries'),
|
||||
choices=CachedCountries(),
|
||||
choices=Countries(),
|
||||
help_text=_('Only allow choosing this payment provider for invoice addresses in the selected '
|
||||
'countries. If you don\'t select any country, all countries are allowed. This is only '
|
||||
'enabled if the invoice address is required.'),
|
||||
@@ -306,7 +306,7 @@ class BasePaymentProvider:
|
||||
initial=['web'],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_(
|
||||
'Only allow the usage of this payment provider in the selected sales channels.'),
|
||||
'Only allow the usage of this payment provider in the following sales channels'),
|
||||
)),
|
||||
('_hidden',
|
||||
forms.BooleanField(
|
||||
@@ -901,16 +901,16 @@ class ManualPayment(BasePaymentProvider):
|
||||
('email_instructions', I18nFormField(
|
||||
label=_('Payment process description in order confirmation emails'),
|
||||
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
|
||||
'mails. It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
|
||||
'mails. It should instruct the user on how to proceed with the payment. You can use'
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||
widget=I18nTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||
)),
|
||||
('pending_description', I18nFormField(
|
||||
label=_('Payment process description for pending orders'),
|
||||
help_text=_('This text will be shown on the order confirmation page for pending orders. '
|
||||
'It should instruct the user on how to proceed with the payment. You can use '
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}.'),
|
||||
'It should instruct the user on how to proceed with the payment. You can use'
|
||||
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
|
||||
widget=I18nTextarea,
|
||||
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
|
||||
)),
|
||||
@@ -1199,7 +1199,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if payment.amount > gc.value: # noqa - just a safeguard
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again"))
|
||||
if gc.expires and gc.expires < now(): # noqa - just a safeguard
|
||||
messages.error(request, _("This gift card is no longer valid."))
|
||||
return
|
||||
|
||||
@@ -109,23 +109,6 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')
|
||||
}),
|
||||
("attendee_company", {
|
||||
"label": _("Attendee company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
|
||||
}),
|
||||
('attendee_address', {
|
||||
'label': _('Full attendee address'),
|
||||
'editor_sample': _('John Doe\nSample company\nSesame Street 42\n12345 Any City\nAtlantis'),
|
||||
'evaluate': lambda op, order, event: op.address_format()
|
||||
}),
|
||||
("attendee_country", {
|
||||
"label": _("Attendee country"),
|
||||
"editor_sample": 'Atlantis',
|
||||
"evaluate": lambda op, order, ev: str(getattr(op.country, 'name', '')) or (
|
||||
str(getattr(op.addon_to.country, 'name', '')) if op.addon_to else ''
|
||||
)
|
||||
}),
|
||||
("event_name", {
|
||||
"label": _("Event name"),
|
||||
"editor_sample": _("Sample event name"),
|
||||
@@ -222,6 +205,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Sample city"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.city if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("attendee_company", {
|
||||
"label": _("Attendee company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: op.company or (op.addon_to.company if op.addon_to else '')
|
||||
}),
|
||||
("addons", {
|
||||
"label": _("List of Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\nAdd-on 2"),
|
||||
|
||||
@@ -81,8 +81,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
||||
logger.exception('Order canceled email could not be sent to attendee')
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,),
|
||||
acks_late=True)
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str,
|
||||
keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False,
|
||||
send: bool=False, send_subject: dict=None, send_message: dict=None,
|
||||
|
||||
@@ -25,7 +25,6 @@ from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.checkin import _save_answers
|
||||
from pretix.base.services.locking import LockTimeoutException, NoLockManager
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import validate_cart_addons
|
||||
@@ -253,12 +252,6 @@ class CartManager:
|
||||
if self._sales_channel not in op.item.sales_channels:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.subevent and op.item.pk in op.subevent.item_overrides and op.subevent.item_overrides[op.item.pk].disabled:
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and op.subevent.var_overrides[op.variation.pk].disabled:
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
if op.item.has_variations and not op.variation:
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
@@ -735,14 +728,10 @@ class CartManager:
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = defaultdict(int)
|
||||
qa = QuotaAvailability()
|
||||
qa.queue(*[k for k, v in self._quota_diff.items() if v > 0])
|
||||
qa.compute(now_dt=self.now_dt)
|
||||
for quota, count in self._quota_diff.items():
|
||||
if count <= 0:
|
||||
quotas_ok[quota] = 0
|
||||
break
|
||||
avail = qa.results[quota]
|
||||
avail = quota.availability(self.now_dt)
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
@@ -895,9 +884,7 @@ class CartManager:
|
||||
available_count = 0
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None,
|
||||
sales_channel=self._sales_channel,
|
||||
distance_ignore_cart_id=self.cart_id):
|
||||
if op.seat and not op.seat.is_available(ignore_voucher_id=op.voucher.id if op.voucher else None, sales_channel=self._sales_channel):
|
||||
available_count = 0
|
||||
err = err or error_messages['seat_unavailable']
|
||||
|
||||
@@ -907,7 +894,6 @@ class CartManager:
|
||||
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
|
||||
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat,
|
||||
override_tax_rate=op.price.rate,
|
||||
price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None
|
||||
)
|
||||
if self.event.settings.attendee_names_asked:
|
||||
@@ -941,7 +927,7 @@ class CartManager:
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event, item=b.item, variation=b.variation,
|
||||
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
|
||||
voucher=None, addon_to=cp, override_tax_rate=b.price.rate,
|
||||
voucher=None, addon_to=cp,
|
||||
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
|
||||
))
|
||||
|
||||
@@ -1033,15 +1019,19 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
for pos in positions:
|
||||
if not pos.item.tax_rule:
|
||||
continue
|
||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||
|
||||
if pos.tax_rate != rate:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||
charge_tax = pos.item.tax_rule.tax_applicable(invoice_address)
|
||||
if pos.includes_tax and not charge_tax:
|
||||
price = pos.item.tax(pos.price, base_price_is='gross').net
|
||||
totaldiff += price - pos.price
|
||||
pos.price = price
|
||||
pos.includes_tax = False
|
||||
pos.save(update_fields=['price', 'includes_tax'])
|
||||
elif charge_tax and not pos.includes_tax:
|
||||
price = pos.item.tax(pos.price, base_price_is='net').gross
|
||||
totaldiff += price - pos.price
|
||||
pos.price = price
|
||||
pos.includes_tax = True
|
||||
pos.save(update_fields=['price', 'includes_tax'])
|
||||
|
||||
return totaldiff
|
||||
|
||||
@@ -1089,14 +1079,23 @@ def get_fees(event, request, total, invoice_address, provider, positions):
|
||||
|
||||
if payment_fee:
|
||||
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
|
||||
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||
value=payment_fee,
|
||||
tax_rate=payment_fee_tax.rate,
|
||||
tax_value=payment_fee_tax.tax,
|
||||
tax_rule=payment_fee_tax_rule
|
||||
))
|
||||
if payment_fee_tax_rule.tax_applicable(invoice_address):
|
||||
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross')
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||
value=payment_fee,
|
||||
tax_rate=payment_fee_tax.rate,
|
||||
tax_value=payment_fee_tax.tax,
|
||||
tax_rule=payment_fee_tax_rule
|
||||
))
|
||||
else:
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||
value=payment_fee,
|
||||
tax_rate=Decimal('0.00'),
|
||||
tax_value=Decimal('0.00'),
|
||||
tax_rule=payment_fee_tax_rule
|
||||
))
|
||||
|
||||
return fees
|
||||
|
||||
|
||||
@@ -1,87 +1,13 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import dateutil
|
||||
from django.db import transaction
|
||||
from django.db.models.functions import TruncDate
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now, override
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
|
||||
Checkin, CheckinList, Order, OrderPosition, Question, QuestionOption,
|
||||
)
|
||||
from pretix.base.signals import checkin_created, order_placed
|
||||
from pretix.helpers.jsonlogic import Logic
|
||||
|
||||
|
||||
def get_logic_environment(ev):
|
||||
def build_time(t=None, value=None):
|
||||
if t == "custom":
|
||||
return dateutil.parser.parse(value)
|
||||
elif t == 'date_from':
|
||||
return ev.date_from
|
||||
elif t == 'date_to':
|
||||
return ev.date_to
|
||||
elif t == 'date_admission':
|
||||
return ev.date_admission or ev.date_from
|
||||
|
||||
def is_before(t1, t2, tolerance=None):
|
||||
if tolerance:
|
||||
return t1 < t2 + timedelta(minutes=float(tolerance))
|
||||
else:
|
||||
return t1 < t2
|
||||
|
||||
logic = Logic()
|
||||
logic.add_operation('objectList', lambda *objs: list(objs))
|
||||
logic.add_operation('lookup', lambda model, pk, str: int(pk))
|
||||
logic.add_operation('inList', lambda a, b: a in b)
|
||||
logic.add_operation('buildTime', build_time)
|
||||
logic.add_operation('isBefore', is_before)
|
||||
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol))
|
||||
return logic
|
||||
|
||||
|
||||
class LazyRuleVars:
|
||||
def __init__(self, position, clist, dt):
|
||||
self._position = position
|
||||
self._clist = clist
|
||||
self._dt = dt
|
||||
|
||||
def __getitem__(self, item):
|
||||
if item[0] != '_' and hasattr(self, item):
|
||||
return getattr(self, item)
|
||||
raise KeyError()
|
||||
|
||||
@property
|
||||
def now(self):
|
||||
return self._dt
|
||||
|
||||
@property
|
||||
def product(self):
|
||||
return self._position.item_id
|
||||
|
||||
@property
|
||||
def variation(self):
|
||||
return self._position.variation_id
|
||||
|
||||
@cached_property
|
||||
def entries_number(self):
|
||||
return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist).count()
|
||||
|
||||
@cached_property
|
||||
def entries_today(self):
|
||||
tz = self._clist.event.timezone
|
||||
midnight = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__gte=midnight).count()
|
||||
|
||||
@cached_property
|
||||
def entries_days(self):
|
||||
tz = self._clist.event.timezone
|
||||
with override(tz):
|
||||
return self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).annotate(
|
||||
day=TruncDate('datetime')
|
||||
).values('day').distinct().count()
|
||||
|
||||
|
||||
class CheckInError(Exception):
|
||||
@@ -136,7 +62,7 @@ def _save_answers(op, answers, given_answers):
|
||||
@transaction.atomic
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY):
|
||||
user=None, auth=None, canceled_supported=False):
|
||||
"""
|
||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||
not valid at this time.
|
||||
@@ -153,11 +79,18 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
"""
|
||||
dt = datetime or now()
|
||||
|
||||
# Lock order positions
|
||||
op = OrderPosition.all.select_for_update().get(pk=op.pk)
|
||||
checkin_questions = list(
|
||||
clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id])
|
||||
)
|
||||
# Fetch order position with related objects
|
||||
op = OrderPosition.all.select_related(
|
||||
'item', 'variation', 'order', 'addon_to'
|
||||
).prefetch_related(
|
||||
'item__questions',
|
||||
Prefetch(
|
||||
'item__questions',
|
||||
queryset=Question.objects.filter(ask_during_checkin=True),
|
||||
to_attr='checkin_questions'
|
||||
),
|
||||
'answers'
|
||||
).get(pk=op.pk)
|
||||
|
||||
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
|
||||
raise CheckInError(
|
||||
@@ -165,25 +98,19 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'canceled' if canceled_supported else 'unpaid'
|
||||
)
|
||||
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
require_answers = []
|
||||
if checkin_questions:
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
for q in checkin_questions:
|
||||
if q not in given_answers and q not in answers:
|
||||
require_answers.append(q)
|
||||
for q in op.item.checkin_questions:
|
||||
if q not in given_answers and q not in answers:
|
||||
require_answers.append(q)
|
||||
|
||||
_save_answers(op, answers, given_answers)
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid product for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid date for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not force and not (
|
||||
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
|
||||
):
|
||||
@@ -197,56 +124,40 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'incomplete',
|
||||
require_answers
|
||||
)
|
||||
else:
|
||||
try:
|
||||
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
|
||||
'datetime': dt,
|
||||
'nonce': nonce,
|
||||
})
|
||||
except Checkin.MultipleObjectsReturned:
|
||||
ci, created = Checkin.objects.filter(position=op, list=clist).last(), False
|
||||
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
logic = get_logic_environment(op.subevent or clist.event)
|
||||
if not logic.apply(clist.rules, rule_data):
|
||||
if created or (nonce and nonce == ci.nonce):
|
||||
if created:
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'first': True,
|
||||
'forced': op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
else:
|
||||
if not force:
|
||||
raise CheckInError(
|
||||
_('This entry is not permitted due to custom rules.'),
|
||||
'rules'
|
||||
_('This ticket has already been redeemed.'),
|
||||
'already_redeemed',
|
||||
)
|
||||
|
||||
device = None
|
||||
if isinstance(auth, Device):
|
||||
device = auth
|
||||
|
||||
last_ci = op.checkins.order_by('-datetime').filter(list=clist).only('type', 'nonce').first()
|
||||
entry_allowed = (
|
||||
type == Checkin.TYPE_EXIT or
|
||||
clist.allow_multiple_entries or
|
||||
last_ci is None or
|
||||
(clist.allow_entry_after_exit and last_ci.type == Checkin.TYPE_EXIT)
|
||||
)
|
||||
|
||||
if nonce and ((last_ci and last_ci.nonce == nonce) or op.checkins.filter(type=type, list=clist, device=device, nonce=nonce).exists()):
|
||||
return
|
||||
|
||||
if entry_allowed or force:
|
||||
ci = Checkin.objects.create(
|
||||
position=op,
|
||||
type=type,
|
||||
list=clist,
|
||||
datetime=dt,
|
||||
device=device,
|
||||
nonce=nonce,
|
||||
forced=force and not entry_allowed,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'first': True,
|
||||
'forced': force or op.order.status != Order.STATUS_PAID,
|
||||
'first': False,
|
||||
'forced': force,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': clist.pk
|
||||
}, user=user, auth=auth)
|
||||
checkin_created.send(op.order.event, checkin=ci)
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket has already been redeemed.'),
|
||||
'already_redeemed',
|
||||
)
|
||||
|
||||
|
||||
@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
|
||||
@@ -261,6 +172,5 @@ def order_placed(sender, **kwargs):
|
||||
for op in order.positions.all():
|
||||
for cl in cls:
|
||||
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
|
||||
if not cl.subevent_id or cl.subevent_id == op.subevent_id:
|
||||
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
|
||||
checkin_created.send(event, checkin=ci)
|
||||
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
|
||||
checkin_created.send(event, checkin=ci)
|
||||
|
||||
@@ -31,9 +31,9 @@ def clean_cached_files(sender, **kwargs):
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clean_cached_tickets(sender, **kwargs):
|
||||
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=3)):
|
||||
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
|
||||
cf.delete()
|
||||
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(days=3)):
|
||||
for cf in CachedCombinedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
|
||||
cf.delete()
|
||||
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(minutes=30), file__isnull=True):
|
||||
cf.delete()
|
||||
|
||||