Compare commits

..

8 Commits

Author SHA1 Message Date
Raphael Michel
2c621c5f3a Bump to 3.11.1 2020-12-22 11:34:00 +01:00
Raphael Michel
df2998dd33 Reduce lifetime of export files 2020-12-22 11:13:58 +01:00
Raphael Michel
de98206abc [SECURITY] Rate limiting for login 2020-12-22 11:13:58 +01:00
Raphael Michel
dd95e807ae [SECURITY] Rate limiting for password change form 2020-12-22 11:13:58 +01:00
Raphael Michel
f08bc6c679 [SECURITY] Bind relevant cached file downloads to the current session 2020-12-22 11:13:58 +01:00
Raphael Michel
a0631ffba5 [SECURITY] Fix unvalidated redirect 2020-12-22 11:03:18 +01:00
Raphael Michel
d7a12cc1ee [SECURITY] Prevent phishing through misleading link titles 2020-12-22 11:03:18 +01:00
Raphael Michel
2239128971 Fix manifest config 2020-09-14 19:00:42 +02:00
293 changed files with 53573 additions and 109586 deletions

View File

@@ -33,8 +33,8 @@ if [ "$1" == "webworker" ]; then
fi
if [ "$1" == "taskworker" ]; then
shift
exec celery -A pretix.celery_app worker -l info "$@"
export C_FORCE_ROOT=True
exec celery -A pretix.celery_app worker -l info
fi
if [ "$1" == "shell" ]; then
@@ -45,4 +45,5 @@ if [ "$1" == "upgrade" ]; then
exec python3 -m pretix updatestyles
fi
exec python3 -m pretix "$@"
echo "Specify argument: all|cron|webworker|taskworker|shell|upgrade"
exit 1

View File

@@ -6099,6 +6099,3 @@ img.screenshot, a.screenshot img {
.versionchanged p:last-child {
margin-bottom: 0;
}
.rst-content td > .line-block {
margin-left: 0 !important;
}

View File

@@ -97,9 +97,6 @@ Example::
``csp_log``
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
``loglevel``
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
Locale settings
---------------

View File

@@ -49,15 +49,11 @@ information on your device as well as your API token:
"device_id": 5,
"unique_serial": "HHZ9LW9JWP390VFZ",
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
"name": "Bar",
"gate": {
"id": 3,
"name": "South entrance"
}
"name": "Bar"
}
Please make sure that you store this ``api_token`` value. We also recommend storing your device ID, your assigned
``unique_serial``, and the ``organizer`` you have access to, but that's up to you. ``gate`` might be ``null``.
``unique_serial``, and the ``organizer`` you have access to, but that's up to you.
In case of an error, the response will look like this:
@@ -102,8 +98,6 @@ following endpoint:
"software_version": "4.1.0"
}
You will receive a response equivalent to the response of your initialization request.
Creating a new API key
----------------------
@@ -132,65 +126,12 @@ invalidate your API key. There is no way to reverse this operation.
This can also be done by the user through the web interface.
Permissions & security profiles
-------------------------------
Permissions
-----------
Device authentication is currently hardcoded to grant the following permissions:
* View event meta data and products etc.
* View orders
* Change orders
* Manage gift cards
* View and change orders
Devices cannot change events or products and cannot access vouchers.
Additionally, when creating a device through the user interface or API, a user can specify a "security profile" for
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
policies for official pretix apps like pretixSCAN and pretixPOS.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Event selection
---------------
In most cases, your application should allow the user to select the event and check-in list they work with manually
from a list. However, in some cases it is required to automatically configure the device for the correct event, for
example in a kiosk-like situation where nobody is operating the device. In this case, the app can query the server
for a suggestion which event should be used. You can also submit the configuration that is currently in use via
query parameters:
.. sourcecode:: http
GET /api/v1/device/eventselection?current_event=democon&current_subevent=42&current_checkinlist=542 HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
You can get three response codes:
* ``304`` The server things you already selected a good event
* ``404`` The server has not found a suggestion for you
* ``200`` The server suggests a new event (body see below)
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"event": "democon",
"subevent": 23,
"checkinlist": 5
}

View File

@@ -25,7 +25,7 @@ Obtaining an authorization grant
--------------------------------
To authorize a new user, link or redirect them to the ``authorize`` endpoint, passing your client ID as a query
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, ``read write`` or ``profile``)
parameter. Additionally, you can pass a scope (currently either ``read``, ``write``, or ``read write``)
and an URL the user should be redirected to after successful or failed authorization. You also need to pass the
``response_type`` parameter with a value of ``code``. Example::
@@ -47,9 +47,11 @@ You will need this ``code`` parameter to perform the next step.
On a failed registration, a query string like ``?error=access_denied`` will be appended to the redirection URL.
.. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the
``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter
to skip user interaction on subsequent calls.
.. note:: In this step, the user is allowed to restrict your access to certain organizer accounts. If you try to
re-authenticate the user later, the user might be instantly redirected back to you if authorization is already
given and would therefore be unable to review their organizer restriction settings. You can append the
``approval_prompt=force`` query parameter if you want to make sure the user actively needs to confirm the
authorization.
Getting an access token
-----------------------
@@ -191,11 +193,10 @@ If you need the user's meta data, you can fetch it here:
Content-Type: application/json
{
"email": "admin@localhost",
"fullname": "John Doe",
"locale": "de",
"is_staff": false,
"timezone": "Europe/Berlin"
email: "admin@localhost",
fullname: "John Doe",
locale: "de",
timezone: "Europe/Berlin"
}
:statuscode 200: no error

View File

@@ -33,7 +33,6 @@ auto_checkin_sales_channels list of strings All items on th
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.
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
@@ -61,10 +60,6 @@ exit_all_at datetime Automatically c
The ``subevent_match`` and ``exclude`` query parameters have been added.
.. versionchanged:: 3.12
The ``exit_all_at`` attribute has been added.
Endpoints
---------
@@ -108,7 +103,6 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"auto_checkin_sales_channels": [
"pretixpos"
@@ -158,7 +152,6 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"auto_checkin_sales_channels": [
"pretixpos"
@@ -195,7 +188,6 @@ Endpoints
{
"checkin_count": 17,
"position_count": 42,
"inside_count": 12,
"event": {
"name": "Demo Conference"
},

View File

@@ -1,224 +0,0 @@
.. spelling:: fullname
.. _`rest-devices`:
Devices
=======
See also :ref:`rest-deviceauth`.
Device resource
----------------
The device resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
device_id integer Internal ID of the device within this organizer
unique_serial string Unique identifier of this device
name string Device name
all_events boolean Whether this device has access to all events
limit_events list List of event slugs this device has access to
hardware_brand string Device hardware manufacturer (read-only)
hardware_model string Device hardware model (read-only)
software_brand string Device software product (read-only)
software_version string Device software version (read-only)
created datetime Creation time
initialized datetime Time of initialization (or ``null``)
initialization_token string Token for initialization
revoked boolean Whether this device no longer has access
security_profile string The name of a supported security profile restricting API access
===================================== ========================== =======================================================
Device endpoints
----------------
.. http:get:: /api/v1/organizers/(organizer)/devices/
Returns a list of all devices within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/devices/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"device_id": 1,
"unique_serial": "UOS3GNZ27O39V3QS",
"initialization_token": "frkso3m2w58zuw70",
"all_events": false,
"limit_events": [
"museum"
],
"revoked": false,
"name": "Scanner",
"created": "2020-09-18T14:17:40.971519Z",
"initialized": "2020-09-18T14:17:44.190021Z",
"security_profile": "full",
"hardware_brand": "Zebra",
"hardware_model": "TC25",
"software_brand": "pretixSCAN",
"software_version": "1.5.1"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/devices/(device_id)/
Returns information on one device, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/devices/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"device_id": 1,
"unique_serial": "UOS3GNZ27O39V3QS",
"initialization_token": "frkso3m2w58zuw70",
"all_events": false,
"limit_events": [
"museum"
],
"revoked": false,
"name": "Scanner",
"created": "2020-09-18T14:17:40.971519Z",
"initialized": "2020-09-18T14:17:44.190021Z",
"security_profile": "full",
"hardware_brand": "Zebra",
"hardware_model": "TC25",
"software_brand": "pretixSCAN",
"software_version": "1.5.1"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param device_id: The ``device_id`` field of the device to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/devices/
Creates a new device
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/devices/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Scanner",
"all_events": true,
"limit_events": [],
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"device_id": 1,
"unique_serial": "UOS3GNZ27O39V3QS",
"initialization_token": "frkso3m2w58zuw70",
"all_events": true,
"limit_events": [],
"revoked": false,
"name": "Scanner",
"created": "2020-09-18T14:17:40.971519Z",
"security_profile": "full",
"initialized": null
"hardware_brand": null,
"hardware_model": null,
"software_brand": null,
"software_version": null
}
:param organizer: The ``slug`` field of the organizer to create a device for
:statuscode 201: no error
:statuscode 400: The device could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/devices/(device_id)/
Update a device.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/devices/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Foo"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Foo",
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param device_id: The ``device_id`` field of the device to modify
:statuscode 200: no error
:statuscode 400: The device could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.

View File

@@ -44,9 +44,6 @@ seat_category_mapping object An object mappi
(strings) to items in the event (integers or ``null``).
timezone string Event timezone name
item_meta_properties object Item-specific meta data parameters and default values.
valid_keys object Cryptographic keys for non-default signature schemes.
For performance reason, value is omitted in lists and
only contained in detail views. Value can be cached.
===================================== ========================== =======================================================
@@ -87,10 +84,6 @@ valid_keys object Cryptographic k
The attribute ``item_meta_properties`` has been added.
.. versionchanged:: 3.12
The attribute ``valid_keys`` has been added.
Endpoints
---------
@@ -151,7 +144,7 @@ Endpoints
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
],
]
}
]
}
@@ -223,12 +216,7 @@ Endpoints
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
],
"valid_keys": {
"pretix_sig1": [
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
]
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -484,7 +472,7 @@ Endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.

View File

@@ -209,15 +209,14 @@ Endpoints
.. sourcecode:: http
POST /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 79
Content-Length: 94
{
"value": "2.00",
"text": "Optional value explaining the transaction"
"value": "2.00"
}
**Example response**:

View File

@@ -24,7 +24,6 @@ Resources and endpoints
giftcards
carts
teams
devices
webhooks
seatingplans
billing_invoices

View File

@@ -201,7 +201,6 @@ addon_to integer Internal ID of
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of check-ins with this ticket
├ id integer Internal ID of the check-in event
├ list integer Internal ID of the check-in list
├ datetime datetime Time of check-in
├ type string Type of scan (defaults to ``entry``)
@@ -1030,10 +1029,6 @@ Creating orders
Order state operations
----------------------
.. versionchanged:: 3.12
The ``mark_paid`` operation now takes a ``send_email`` parameter.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
Marks a pending or expired order as successfully paid.
@@ -1045,11 +1040,6 @@ Order state operations
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_paid/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"send_email": true
}
**Example response**:
@@ -1732,10 +1722,6 @@ Order payment endpoints
Payments can now be created through the API.
.. versionchanged:: 3.12
The ``confirm`` operation now takes a ``send_email`` parameter.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Returns a list of all payments for an order.
@@ -1836,10 +1822,7 @@ Order payment endpoints
Accept: application/json, text/javascript
Content-Type: application/json
{
"send_email": true,
"force": false
}
{"force": false}
**Example response**:
@@ -2263,57 +2246,3 @@ Order refund endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or refund does not exist.
Revoked ticket secrets
----------------------
With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation.
.. versionchanged:: 3.12
Added revocation lists.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/
Returns a list of all revoked secrets within a given event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/revokedsecrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1234,
"secret": "k24fiuwvu8kxz3y1",
"created": "2017-12-01T10:00:00Z",
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``secret`` and ``created``. Default: ``-created``
:query datetime created_since: Only return revocations that have been created since the given date.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
differences, this is the value you want to use as ``created_since`` in your next call.
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.

View File

@@ -51,7 +51,6 @@ seating_plan integer If reserved sea
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
.. versionchanged:: 1.7
@@ -81,10 +80,6 @@ last_modified datetime Last modificati
The ``disabled`` attribute has been added to ``item_price_overrides`` and ``variation_price_overrides``.
.. versionchanged:: 3.12
The ``last_modified`` attribute has been added.
Endpoints
---------
@@ -153,8 +148,6 @@ Endpoints
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:query datetime modified_since: Only return objects that have changed since the given date. Be careful: This does not
allow you to know if a subevent was deleted.
:query array attr[meta_data_key]: By providing the key and value of a meta data attribute, the list of sub-events
will only contain the sub-events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return
only those sub-events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that

View File

@@ -52,7 +52,6 @@ extensions = [
'sphinx.ext.coverage',
'sphinxcontrib.httpdomain',
'sphinxcontrib.images',
'sphinxemoji.sphinxemoji',
]
if HAS_PYENCHANT:
extensions.append('sphinxcontrib.spelling')

View File

@@ -12,8 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter
Order events
""""""""""""
@@ -34,7 +33,7 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
.. automodule:: pretix.presale.signals

View File

@@ -136,7 +136,7 @@ in the ``installed`` method::
pass # Your code here
Note that ``installed`` will *not* be called if the plugin is indirectly activated for an event
Note that ``installed`` will *not* be called if the plugin in indirectly activated for an event
because the event is created with settings copied from another event.
Views
@@ -151,8 +151,8 @@ your Django app label.
with checking that the calling user is logged in, has appropriate permissions,
etc. We plan on providing native support for this in a later version.
.. _Django app: https://docs.djangoproject.com/en/3.0/ref/applications/
.. _signal dispatcher: https://docs.djangoproject.com/en/3.0/topics/signals/
.. _namespace packages: https://legacy.python.org/dev/peps/pep-0420/
.. _Django app: https://docs.djangoproject.com/en/1.7/ref/applications/
.. _signal dispatcher: https://docs.djangoproject.com/en/1.7/topics/signals/
.. _namespace packages: http://legacy.python.org/dev/peps/pep-0420/
.. _entry point: https://setuptools.readthedocs.io/en/latest/pkg_resources.html#locating-plugins
.. _cookiecutter: https://cookiecutter.readthedocs.io/en/latest/

View File

@@ -117,7 +117,7 @@ for example, to check for any errors in any staged files when committing::
export GIT_WORK_TREE=../
export GIT_DIR=../.git
source ../env/bin/activate # Adjust to however you activate your virtual environment
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py|.*_pb2\.py")
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
do
echo $file
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes

View File

@@ -34,8 +34,6 @@ transactions list of objects Transactions in
├ payer string Payment source
├ reference string Payment reference
├ amount string Payment amount
├ iban string Payment IBAN
├ bic string Payment BIC
├ date string Payment date (in **user-inputted** format)
├ order string Associated order code (or ``null``)
└ comment string Internal comment
@@ -85,8 +83,6 @@ Endpoints
"date": "26.06.2017",
"payer": "John Doe",
"order": null,
"iban": "",
"bic": "",
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
"reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…",
"state": "nomatch",
@@ -136,8 +132,6 @@ Endpoints
"comment": "",
"date": "26.06.2017",
"payer": "John Doe",
"iban": "",
"bic": "",
"order": null,
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
"reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…",

View File

@@ -3,8 +3,7 @@ sphinx==2.3.*
sphinx-rtd-theme
sphinxcontrib-httpdomain
sphinxcontrib-images
sphinxcontrib-spelling==4.*
sphinxemoji
sphinxcontrib-spelling
pygments-markdown-lexer
# See https://github.com/rfk/pyenchant/pull/130
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant

View File

@@ -10,11 +10,7 @@ availabilities
backend
backends
banktransfer
barcode
barcodes
Bcc
bic
BIC
boolean
booleans
cancelled
@@ -51,15 +47,12 @@ gunicorn
guid
hardcoded
hostname
iban
IBAN
ics
idempotency
iframe
incrementing
inofficial
invalidations
iOS
iterable
Jimdo
jwt
@@ -98,9 +91,7 @@ prepending
preprocessor
presale
pretix
pretixSCAN
pretixdroid
pretixPOS
pretixpresale
prometheus
proxied

View File

@@ -3,8 +3,6 @@
Warengutschein
Wertgutschein
.. _giftcards:
Gift cards
==========

View File

@@ -1,93 +0,0 @@
Ticket secret generators
========================
pretix allows you to change the way in which ticket secrets (also known as "ticket codes", "barcodes", …)
are generated. This affects the value of the QR code in any tickets issued by pretix, regardless of ticket
format.
.. note:: This is intended for highly advanced use cases, usually when huge numbers of tickets (> 25k per event)
are involved. **If you don't know whether you need this, you probably don't.**
Default: Random secrets
-----------------------
By default, pretix generates a random code for every ticket, consisting of 32 lower case characters and
numbers. The characters ``oO1il`` are avoided to reduce confusion when ticket codes are printed and need to
be typed in manually.
Choosing random codes has a number of advantages:
* Ticket codes are short, which makes QR codes easier to scan. At the same time, it is absolutely impossible to
guess or forge a valid ticket code.
* The code does not need to change if the ticket changes. For example, if an attendee is re-booked to a
different product or date, they can keep their ticket and it is just mapped to the new product in the
database.
This approach works really well for 99 % or events running with pretix.
The big caveat is that the scanner needs to access a database of all ticket codes in order to know whether a ticket
code is valid and what kind of ticket it represents.
When scanning online this is no problem at all, since the pretix server always has such a database. In case your local
internet connection is interrupted or the pretix server goes down, though, there needs to be a database locally on the
scanner.
Therefore, our pretixSCAN apps by default download the database of all valid tickets onto the device itself. This makes
it possible to seamlessly switch into offline mode when the connection is lost and continue scanning with the maximum
possible feature set.
There are a few situations in which this approach is not ideal:
* When running a single event with 25k or more valid tickets, downloading all ticket data onto the scanner may just
take too much time and resources.
* When the risk of losing sensible data by losing one of the scanner devices is not acceptable.
* When offline mode needs to be used regularly and newly-purchased tickets need to be valid immediately after purchase,
without being able to tolerate a few minutes of delay.
Signature schemes
-----------------
The alternative approach that is included with pretix is to choose a signature-based ticket code generation scheme.
These secrets include the most important information that is required for verifying their validity and use modern
cryptography to make sure they cannot be forged.
Currently, pretix ships with one such scheme ("pretix signature scheme 1") which encodes the product, the product
variation, and the date (if inside an event series) into the ticket code and signs the code with a `EdDSA`_ signature.
This allows to verify whether a ticket is allowed to enter without any database or connection to the server, but has
a few important drawbacks:
* Whenever the product, variation or date of a ticket changes or the ticket is canceled, the ticket code needs to be
changed and the old code needs to be put on a revocation list. This revocation list again needs to be downloaded by
all scanning devices (but is usually much smaller than the ticket database). The main downside is that the attendee
needs to download their new ticket and can no longer use the old one.
* Scanning in offline mode is much more limited, since the scanner has no information about previous usages of the
ticket, attendee names, seating information, etc.
Comparison of scanning behavior
-------------------------------
=============================================== =================================== =================================== =================================== ================================= =====================================
Scan mode Online Offline
----------------------------------------------- ----------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------
Synchronization setting any Synchronize orders Don't synchronize orders
----------------------------------------------- ----------------------------------- ----------------------------------------------------------------------- -----------------------------------------------------------------------
Ticket secrets any Random Signed Random Signed
=============================================== =================================== =================================== =================================== ================================= =====================================
Scenario supported on platforms Android, Desktop, iOS Android, Desktop, iOS Android, Desktop Android, Desktop Android, Desktop
Synchronization speed for large data sets slow slow fast fast
Tickets can be scanned yes yes yes no yes
Ticket is valid after sale immediately next sync (~5 minutes) immediately never immediately
Same ticket can be scanned multiple times no yes, before data is synced yes, before data is synced n/a yes, always
Custom check-in rules yes yes yes (limited directly after sale) n/a yes, but only based on product,
variation and date, not on previous
scans
Name and seat visible on scanner yes yes yes (except directly after sale) n/a no
Order-specific check-in attention flag yes yes yes (except directly after sale) n/a no
Ticket search by order code or name yes yes yes (except directly after sale) no no
Check-in statistics on scanner yes yes mostly accurate no no
=============================================== =================================== =================================== =================================== ================================= =====================================
.. _EdDSA: https://en.wikipedia.org/wiki/EdDSA#Ed25519

View File

@@ -9,33 +9,26 @@ At "Settings" → "Tickets", you can configure the ticket download options that
The top of this page shows a short list of options relevant for all download formats:
Allow users to download tickets
Use feature
This can be used to completely enable or disable ticket downloads all over your ticket shop.
Generate tickets for add-on products
By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If
you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled,
you can still print a list of chosen add-ons e.g. on the PDF tickets.
Generate tickets for all products
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
generate tickets for all products instead.
Generate tickets for pending orders
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
the event, you can check this box to enable ticket download even before.
Download date
If you set a date here, no ticket download will be offered before this date. If no date is set, tickets can be
downloaded immediately after the payment for an order has been received.
Offer to download tickets separately for add-on products
By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If
you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled,
you can still print a list of chosen add-ons e.g. on the PDF tickets.
Generate tickets for non-admission products
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
generate tickets for all products instead.
Offer to download tickets even before an order is paid
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
the event, you can check this box to enable ticket download even before.
Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to
format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with
a PDF output plugin that you can configure through a visual design editor.
**Advanced topics:**
.. toctree::
:maxdepth: 1
ticket_secrets
a PDF output plugin that you can configure through a visual design editor.

View File

@@ -1,5 +1,3 @@
.. _widget:
Embeddable Widget
=================

View File

@@ -1,181 +0,0 @@
Glossary
========
This page gives definitions of domain-specific terms that we use a lot inside pretix and that might be used slightly
differently elsewhere, as well as their official translations to other languages. In some cases, things have a different
name internally, which is noted with a |:wrench:| symbol. If you only use pretix, you'll never see these, but if you're
going to develop around pretix, for example connect to pretix through our API, you need to know these as well.
.. rst-class:: rest-resource-table
.. list-table:: Glossary
:widths: 15 30
:header-rows: 1
* - Term
- Definition
* - | |:gb:| **Organizer**
| |:de:| Veranstalter
- An organizer represents the entity using pretix, usually the company or institution running one or multiple events.
In terms of navigation in the system, organizers are the "middle layer" between the system itself and the specific
events.
Multiple organizers on the same pretix system are fully separated from each other with very few exceptions.
* - | |:gb:| **Event**
| |:de:| Veranstaltung
- An event is the central entity in pretix that you and your customers interact with all the time. An event
represents one **shop** in which things like tickets can be bought. Since the introduction of event series (see
below), this might include multiple events in the real world.
Every purchase needs to be connected to an event, and most things are completely separate between different
events, i.e. most actions and configurations in pretix are done per-event.
* - | |:gb:| **Event series**
| |:de:| Veranstaltungsreihe
- An event series is one of two types of events. Unlike a non-series event, an event series groups together
multiple real-world events into one pretix shop. Examples are time-slot-based booking for a museum, a band on
tour, a theater group playing the same play multiple times, etc.
* - | |:gb:| **Date**
| |:de:| Termin
| |:wrench:| Subevent
- A date represents a single real-world event inside an event series. Dates can differ from each other in name,
date, time, location, pricing, capacity, and seating plans, but otherwise share the same configuration.
* - | |:gb:| **Product**
| |:de:| Produkt
| |:wrench:| Item
- A product is anything that can be sold, such as a specific type of ticket or merchandise.
* - | |:gb:| **Admission product**
| |:de:| Zutrittsprodukt
- A product is considered an **admission product** if its purchase represents a person being granted access to your
event. This applies to most ticketing products, but not e.g. to merchandise.
* - | |:gb:| **Variation**
| |:de:| Variante
| |:wrench:| Item variation
- Some products come in multiple variations that can differ in description, price and capacity. Examples would
include "Adult" and "Child" in case of a concert ticket, or "S", "M", "L", … in case of a t-shirt product.
* - | |:gb:| **Category**
| |:de:| Kategorie
- Products can be grouped together in categories. This is mostly to organize them cleanly in the frontend if you
have lots of them.
* - | |:gb:| **Quota**
| |:de:| Kontingent
- A quota is a capacity pool that defines how many times a product can be sold. A quota can be connected to multiple
products, in which case all of them are counted together. This is useful e.g. if you have full-price and reduced
tickets and only want to sell a certain number of tickets in total. The same way, multiple quotas can be connected
to the same product, in which case the ticket will be available as long as all of them have capacity left.
* - | |:gb:| **Add-on product**
| |:de:| Zusatzprodukt
- An add-on product is a product that is purchased as an upgrade or optional addition to a different product.
Examples would be include a conference ticket that optionally allows to buy a public transport ticket for the
same day, or a family ticket for 4 persons that allows you to add additional persons at a small cost, or a
"two workshops" package that allows you to select two of a larger number of workshops at a discounted price.
In all cases, there is a "main product" (the conference ticket, the family ticket) and a number of "add-on products"
that can be chosen from.
* - | |:gb:| **Bundled product**
| |:de:| Enthaltenes Produkt
- A bundled product is a product that is automatically put into the cart when another product is purchased. It's
similar to an add-on product, except that the customer has no choice between whether it is added or which of a
set of product is added.
* - | |:gb:| **Question**
| |:de:| Frage
- A question is a custom field that customers need to fill in when purchasing a specific product.
* - | |:gb:| **Voucher**
| |:de:| Gutschein
- A voucher is a code that can be used for multiple purposes: To grant a discount to specific customers, to only
show certain products to certain customers, or to keep a seat open for someone specific even though you are
sold out. If a voucher is used to apply a discount, the price of the purchased product is reduced by the
discounted amount. Vouchers are connected to a specific event.
* - | |:gb:| **Gift card**
| |:de:| Geschenkgutschein
- A :ref:`gift card <giftcards>` is a coupon representing an exact amount of money that can be used for purchases
of any kind. Gift cards can be sold, created manually, or used as a method to refund your customer without paying
them back directly.
Unlike a voucher, it does not reduce the price of the purchased products when redeemed, but instead works as a
payment method to lower the amount that needs to be paid through other methods. Gift cards are specific to an
organizer by default but can even by shared between organizers.
* - | |:gb:| **Cart**
| |:de:| Warenkorb
- A cart is a collection of products that are reserved by a customer who is currently completing the checkout
process, but has not yet finished it.
* - | |:gb:| **Order**
| |:de:| Bestellung
- An order is a purchase by a client, containing multiple different products. An order goes through various
states and can change during its lifetime.
* - | |:gb:| **Order code**
| |:de:| Bestellnummer
- An order code is the unique identifier of an order, usually consisting of 5 numbers and letters.
* - | |:gb:| **Order position**
| |:de:| Bestellposition
- An order position is a single line inside an order, representing the purchase of one specific product. If the
product is an admission product, this represents an attendee.
* - | |:gb:| **Attendees**
| |:de:| Teilnehmende
- An attendee is the person designated to use a specific order position to access the event.
* - | |:gb:| **Fee**
| |:de:| Gebühr
- A fee is an additional type of line inside an order that represents a cost that needs to be paid by the customer,
but is not related to a specific product. A typical example is a shipping fee.
* - | |:gb:| **Invoice** and **Cancellation**
| |:de:| Rechnung und Rechnungskorrektur
- An invoice refers to a legal document created to document a purchase for tax purposes. Invoices have individual
numbers and no longer change after they have been issued. Every invoice is connected to an order, but an order
can have multiple invoices: If an order changes, a cancellation document is created for the old invoice and a
new invoice is created.
* - | |:gb:| **Check-in**
| |:de:| Check-in
- A check-in is the event of someone being successfully scanned at an entry or exit of the event.
* - | |:gb:| **Check-in list**
| |:de:| Check-in-Liste
- A check-in list is used to configure who can be scanned at a specific entry or exit of the event. Check-in lists
are isolated from each other, so by default each ticket is valid once on every check-in list individually. They
are therefore often used to represent *parts* of an event, either time-wise (e.g. conference days) or space-wise
(e.g. rooms).
* - | |:gb:| **Plugin**
| |:de:| Erweiterung
- A plugin is an optional software module that contains additional functionality and can be turned on and off per
event. If you host pretix on your own server, most plugins need to be installed separately.
* - | |:gb:| **Tax rule**
| |:de:| Steuer-Regel
- A tax rule defines how sales taxes are calculated for a product, possibly depending on type and country of the
customer.
* - | |:gb:| **Ticket**
| |:de:| Ticket
- A ticket usually refers to the actual file presented to the customer to be used at check-in, i.e. the PDF or
Passbook file carrying the QR code. In some cases, "ticket" may also be used to refer to an order position,
especially in case of admission products.
* - | |:gb:| **Ticket secret**
| |:de:| Ticket-Code
- The ticket secret (sometimes "ticket code") is what's contained in the QR code on the ticket.
* - | |:gb:| **Badge**
| |:de:| Badge
- A badge refers to the file used as a name tag for an attendee of your event.
* - | |:gb:| **User**
| |:de:| Benutzer
- A user is anyone who can sign into the backend interface of pretix.
* - | |:gb:| **Team**
| |:de:| Team
- A :ref:`team <user-teams>` is a collection of users who are granted some level of access to a set of events.
* - | |:gb:| **Device**
| |:de:| Gerät
- A device is something that talks to pretix but does not run on a server. Usually a device refers to an
installation of pretixSCAN, pretixPOS or some compatible third-party app on one of your computing devices.
* - | |:gb:| **Gate**
| |:de:| Station
- A gate is a location at your event where people are being scanned, e.g. an entry or exit door. You can configure
gates in pretix to group multiple devices together that are used in the same location, mostly for statistical
purposes.
* - | |:gb:| **Widget**
| |:de:| Widget
- The :ref:`widget` is a JavaScript component that can be used to embed the shop of an event or a list of events
into a third-party web page.
* - | |:gb:| **Sales channel**
| |:de:| Verkaufskanal
- A sales channel refers to the type in which a purchase arrived in the system, e.g. through pretix' web shop itself,
or through other channels like box office or reseller sales.
* - | |:gb:| **Box office**
| |:de:| Abendkasse
- Box office purchases refer to all purchases made in-person from the organizer directly, through a point of sale
system like pretixPOS.
* - | |:gb:| **Reseller**
| |:de:| Vorverkaufsstelle
- Resellers are third-party entities offering in-person sales of events to customers.

View File

@@ -15,4 +15,3 @@ wanting to use pretix to sell tickets.
events/giftcards
faq
markdown
glossary

View File

@@ -1,6 +1,5 @@
include LICENSE
include README.rst
global-include *.proto
recursive-include pretix/static *
recursive-include pretix/static.dist *
recursive-include pretix/locale *

View File

@@ -1 +1 @@
__version__ = "3.13.0.dev0"
__version__ = "3.11.1"

View File

@@ -3,9 +3,6 @@ from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from pretix.api.auth.devicesecurity import (
DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile,
)
from pretix.base.models import Device
@@ -28,11 +25,3 @@ class DeviceTokenAuthentication(TokenAuthentication):
raise exceptions.AuthenticationFailed('Device access has been revoked.')
return AnonymousUser(), device
def authenticate(self, request):
r = super().authenticate(request)
if r and isinstance(r[1], Device):
profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile)
if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.')
return r

View File

@@ -1,121 +0,0 @@
from django.utils.translation import ugettext_lazy as _
class FullAccessSecurityProfile:
identifier = 'full'
verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)')
def is_allowed(self, request):
return True
class AllowListSecurityProfile:
allowlist = tuple()
def is_allowed(self, request):
key = (request.method, f"{request.resolver_match.namespace}:{request.resolver_match.url_name}")
return key in self.allowlist
class PretixScanSecurityProfile(AllowListSecurityProfile):
identifier = 'pretixscan'
verbose_name = _('pretixSCAN')
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
('GET', 'api-v1:event-list'),
('GET', 'api-v1:event-detail'),
('GET', 'api-v1:subevent-list'),
('GET', 'api-v1:subevent-detail'),
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
('GET', 'api-v1:checkinlist-status'),
('GET', 'api-v1:checkinlistpos-list'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:order-list'),
('GET', 'api-v1:event.settings'),
)
class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
identifier = 'pretixscan_online_kiosk'
verbose_name = _('pretixSCAN (kiosk mode, online only)')
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
('GET', 'api-v1:event-list'),
('GET', 'api-v1:event-detail'),
('GET', 'api-v1:subevent-list'),
('GET', 'api-v1:subevent-detail'),
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:badgelayout-list'),
('GET', 'api-v1:badgeitem-list'),
('GET', 'api-v1:checkinlist-list'),
('GET', 'api-v1:checkinlist-status'),
('POST', 'api-v1:checkinlistpos-redeem'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),
)
class PretixPosSecurityProfile(AllowListSecurityProfile):
identifier = 'pretixpos'
verbose_name = _('pretixPOS')
allowlist = (
('GET', 'api-v1:version'),
('GET', 'api-v1:device.eventselection'),
('POST', 'api-v1:device.update'),
('POST', 'api-v1:device.revoke'),
('POST', 'api-v1:device.roll'),
('GET', 'api-v1:event-list'),
('GET', 'api-v1:event-detail'),
('GET', 'api-v1:subevent-list'),
('GET', 'api-v1:subevent-detail'),
('GET', 'api-v1:itemcategory-list'),
('GET', 'api-v1:item-list'),
('GET', 'api-v1:question-list'),
('GET', 'api-v1:quota-list'),
('GET', 'api-v1:taxrule-list'),
('GET', 'api-v1:ticketlayout-list'),
('GET', 'api-v1:ticketlayoutitem-list'),
('GET', 'api-v1:order-list'),
('POST', 'api-v1:order-list'),
('GET', 'api-v1:order-detail'),
('DELETE', 'api-v1:orderposition-detail'),
('POST', 'api-v1:order-mark_canceled'),
('POST', 'api-v1:orderrefund-list'),
('POST', 'api-v1:orderrefund-done'),
('POST', 'api-v1:cartposition-list'),
('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'),
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'),
)
DEVICE_SECURITY_PROFILES = {
k.identifier: k() for k in (
FullAccessSecurityProfile,
PretixScanSecurityProfile,
PretixScanNoSyncSecurityProfile,
PretixPosSecurityProfile,
)
}

View File

@@ -84,15 +84,3 @@ class EventCRUDPermission(EventPermission):
return False
return True
class ProfilePermission(BasePermission):
def has_permission(self, request, view):
if not request.user.is_authenticated:
return False
if isinstance(request.auth, OAuthAccessToken):
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
return False
return True

View File

@@ -9,7 +9,7 @@ from oauth2_provider.settings import oauth2_settings
class Validator(OAuth2Validator):
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
if not getattr(request, 'organizers', None) and request.scopes != ['profile']:
if not getattr(request, 'organizers', None):
raise FatalClientError('No organizers selected.')
expires = timezone.now() + timedelta(
@@ -18,8 +18,7 @@ class Validator(OAuth2Validator):
expires=expires, redirect_uri=request.redirect_uri,
scope=" ".join(request.scopes))
g.save()
if request.scopes != ['profile']:
g.organizers.add(*request.organizers.all())
g.organizers.add(*request.organizers.all())
def validate_code(self, client_id, code, client, request, *args, **kwargs):
try:
@@ -35,14 +34,12 @@ class Validator(OAuth2Validator):
return False
def _create_access_token(self, expires, request, token, source_refresh_token=None):
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token', None) and token["scope"] != 'profile':
if not getattr(request, 'organizers', None) and not getattr(source_refresh_token, 'access_token'):
raise FatalClientError('No organizers selected.')
if token['scope'] != 'profile':
if hasattr(request, 'organizers'):
orgs = list(request.organizers.all())
else:
orgs = list(source_refresh_token.access_token.organizers.all())
if hasattr(request, 'organizers'):
orgs = list(request.organizers.all())
else:
orgs = list(source_refresh_token.access_token.organizers.all())
access_token = super()._create_access_token(expires, request, token, source_refresh_token=None)
if token['scope'] != 'profile':
access_token.organizers.add(*orgs)
access_token.organizers.add(*orgs)
return access_token

View File

@@ -15,7 +15,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
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', 'exit_all_at')
'rules')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -95,41 +95,19 @@ class TimeZoneField(ChoiceField):
)
class ValidKeysField(Field):
def to_representation(self, value):
return value.cache.get_or_set(
'ticket_secret_valid_keys',
lambda: self._get(value),
120
)
def _get(self, value):
return {
'pretix_sig1': [
value.settings.ticket_secrets_pretix_sig1_pubkey
] if value.settings.ticket_secrets_pretix_sig1_pubkey else []
}
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
item_meta_properties = MetaPropertyField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
valid_keys = ValidKeysField(source='*', read_only=True)
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(self.context['request'], 'event'):
self.fields.pop('valid_keys')
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties')
def validate(self, data):
data = super().validate(data)
@@ -391,7 +369,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public',
'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data',
'seat_category_mapping', 'last_modified')
'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -554,7 +532,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:
model = TaxRule
fields = ('id', 'name', 'rate', 'price_includes_tax')
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
class EventSettingsSerializer(serializers.Serializer):
@@ -611,7 +589,6 @@ class EventSettingsSerializer(serializers.Serializer):
'ticket_download_addons',
'ticket_download_nonadm',
'ticket_download_pending',
'ticket_download_require_validated_email',
'mail_prefix',
'mail_from',
'mail_from_name',

View File

@@ -21,7 +21,7 @@ from pretix.base.models import (
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
CartPosition, OrderFee, OrderPayment, OrderRefund,
)
from pretix.base.pdf import get_variables
from pretix.base.services.cart import error_messages
@@ -122,7 +122,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
class CheckinSerializer(I18nAwareModelSerializer):
class Meta:
model = Checkin
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'type')
fields = ('datetime', 'list', 'auto_checked_in', 'type')
class OrderDownloadsField(serializers.Field):
@@ -1209,10 +1209,3 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
order.save()
return order
class RevokedTicketSecretSerializer(I18nAwareModelSerializer):
class Meta:
model = RevokedTicketSecret
fields = ('id', 'secret', 'created')

View File

@@ -9,8 +9,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.base.auth import get_auth_backends
from pretix.base.models import (
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
@@ -67,6 +66,9 @@ class EventSlugField(serializers.SlugRelatedField):
class TeamSerializer(serializers.ModelSerializer):
limit_events = EventSlugField(slug_field='slug', many=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Meta:
model = Team
fields = (
@@ -84,28 +86,6 @@ class TeamSerializer(serializers.ModelSerializer):
return data
class DeviceSerializer(serializers.ModelSerializer):
limit_events = EventSlugField(slug_field='slug', many=True)
device_id = serializers.IntegerField(read_only=True)
unique_serial = serializers.CharField(read_only=True)
hardware_brand = serializers.CharField(read_only=True)
hardware_model = serializers.CharField(read_only=True)
software_brand = serializers.CharField(read_only=True)
software_version = serializers.CharField(read_only=True)
created = serializers.DateTimeField(read_only=True)
revoked = serializers.BooleanField(read_only=True)
initialized = serializers.DateTimeField(read_only=True)
initialization_token = serializers.DateTimeField(read_only=True)
class Meta:
model = Device
fields = (
'device_id', 'unique_serial', 'initialization_token', 'all_events', 'limit_events',
'revoked', 'name', 'created', 'initialized', 'hardware_brand', 'hardware_model',
'software_brand', 'software_version', 'security_profile'
)
class TeamInviteSerializer(serializers.ModelSerializer):
class Meta:
model = TeamInvite

View File

@@ -21,7 +21,6 @@ orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -39,14 +38,13 @@ event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
question_router = routers.DefaultRouter()
question_router.register(r'options', item.QuestionOptionViewSet)
@@ -86,7 +84,6 @@ urlpatterns = [
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
url(r"^me$", user.MeView.as_view(), name="user.me"),
url(r"^version$", version.VersionView.as_view(), name="version"),
]

View File

@@ -1,8 +1,6 @@
import django_filters
from django.core.exceptions import ValidationError
from django.db.models import (
Count, Exists, F, Max, OuterRef, Prefetch, Q, Subquery,
)
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
from django.db.models.functions import Coalesce
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -20,7 +18,6 @@ from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import CheckinListOrderPositionSerializer
from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, CheckinList, Event, Order, OrderPosition,
)
@@ -90,67 +87,73 @@ class CheckinListViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['GET'])
def status(self, *args, **kwargs):
with language(self.request.event.settings.locale):
clist = self.get_object()
cqs = clist.positions.annotate(
checkedin=Exists(Checkin.objects.filter(list_id=clist.pk, position=OuterRef('pk'), type=Checkin.TYPE_ENTRY))
).filter(
checkedin=True,
)
pqs = clist.positions
clist = self.get_object()
cqs = Checkin.objects.filter(
position__order__event=clist.event,
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
list=clist
)
pqs = OrderPosition.objects.filter(
order__event=clist.event,
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if clist.include_pending else []),
)
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))
ev = clist.subevent or clist.event
response = {
'event': {
'name': str(ev.name),
},
'checkin_count': cqs.count(),
'position_count': pqs.count(),
'inside_count': clist.inside_count,
}
ev = clist.subevent or clist.event
response = {
'event': {
'name': str(ev.name),
},
'checkin_count': cqs.count(),
'position_count': pqs.count()
}
op_by_item = {
p['item']: p['cnt']
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
}
op_by_variation = {
p['variation']: p['cnt']
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
}
c_by_item = {
p['item']: p['cnt']
for p in cqs.order_by().values('item').annotate(cnt=Count('id'))
}
c_by_variation = {
p['variation']: p['cnt']
for p in cqs.order_by().values('variation').annotate(cnt=Count('id'))
}
op_by_item = {
p['item']: p['cnt']
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
}
op_by_variation = {
p['variation']: p['cnt']
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
}
c_by_item = {
p['position__item']: p['cnt']
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
}
c_by_variation = {
p['position__variation']: p['cnt']
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
}
if not clist.all_products:
items = clist.limit_products
else:
items = clist.event.items
if not clist.all_products:
items = clist.limit_products
else:
items = clist.event.items
response['items'] = []
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
i = {
'id': item.pk,
'name': str(item),
'admission': item.admission,
'checkin_count': c_by_item.get(item.pk, 0),
'position_count': op_by_item.get(item.pk, 0),
'variations': []
}
for var in item.variations.all():
i['variations'].append({
'id': var.pk,
'value': str(var),
'checkin_count': c_by_variation.get(var.pk, 0),
'position_count': op_by_variation.get(var.pk, 0),
})
response['items'].append(i)
response['items'] = []
for item in items.order_by('category__position', 'position', 'pk').prefetch_related('variations'):
i = {
'id': item.pk,
'name': str(item),
'admission': item.admission,
'checkin_count': c_by_item.get(item.pk, 0),
'position_count': op_by_item.get(item.pk, 0),
'variations': []
}
for var in item.variations.all():
i['variations'].append({
'id': var.pk,
'value': str(var),
'checkin_count': c_by_variation.get(var.pk, 0),
'position_count': op_by_variation.get(var.pk, 0),
})
response['items'].append(i)
return Response(response)
return Response(response)
with scopes_disabled():
@@ -257,7 +260,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
return qs
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>.*)/redeem')
@action(detail=False, methods=['POST'], url_name='redeem', url_path='(?P<pk>[^/]+)/redeem')
def redeem(self, *args, **kwargs):
force = bool(self.request.data.get('force', False))
type = self.request.data.get('type', None) or Checkin.TYPE_ENTRY
@@ -278,23 +281,13 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
else:
op = queryset.get(secret=self.kwargs['pk'])
except OrderPosition.DoesNotExist:
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
if len(revoked_matches) == 0 or not force:
self.request.event.log_action('pretix.event.checkin.unknown', data={
'datetime': dt,
'type': type,
'list': self.checkinlist.pk,
'barcode': self.kwargs['pk']
}, user=self.request.user, auth=self.request.auth)
raise Http404()
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
self.request.event.log_action('pretix.event.checkin.unknown', data={
'datetime': dt,
'type': type,
'list': self.checkinlist.pk,
'barcode': self.kwargs['pk']
}, user=self.request.user, auth=self.request.auth)
raise Http404()
given_answers = {}
if 'answers' in self.request.data:
@@ -335,7 +328,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'position': op.id,
'positionid': op.positionid,
'errorcode': e.code,
'force': force,
'datetime': dt,
'type': type,
'list': self.checkinlist.pk

View File

@@ -1,16 +1,14 @@
import logging
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Coalesce
from django.utils.timezone import now
from rest_framework import serializers, status
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.base.models import CheckinList, Device, SubEvent
from pretix.base.models.devices import Gate, generate_api_token
from pretix.base.models import Device
from pretix.base.models.devices import generate_api_token
logger = logging.getLogger(__name__)
@@ -30,25 +28,14 @@ class UpdateRequestSerializer(serializers.Serializer):
software_version = serializers.CharField(max_length=190)
class GateSerializer(serializers.ModelSerializer):
class Meta:
model = Gate
fields = [
'id',
'name',
'identifier',
]
class DeviceSerializer(serializers.ModelSerializer):
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
gate = GateSerializer(read_only=True)
class Meta:
model = Device
fields = [
'organizer', 'device_id', 'unique_serial', 'api_token',
'name', 'security_profile', 'gate'
'name'
]
@@ -124,156 +111,3 @@ class RevokeKeyView(APIView):
serializer = DeviceSerializer(device)
return Response(serializer.data)
class EventSelectionView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
@property
def base_event_qs(self):
qs = self.request.auth.organizer.events.annotate(
first_date=Coalesce('date_admission', 'date_from'),
last_date=Coalesce('date_to', 'date_from'),
).filter(
live=True,
has_subevents=False
).order_by('first_date')
if self.request.auth.gate:
has_cl = CheckinList.objects.filter(
event=OuterRef('pk'),
gates__in=[self.request.auth.gate]
)
qs = qs.annotate(has_cl=Exists(has_cl)).filter(has_cl=True)
return qs
@property
def base_subevent_qs(self):
qs = SubEvent.objects.annotate(
first_date=Coalesce('date_admission', 'date_from'),
last_date=Coalesce('date_to', 'date_from'),
).filter(
event__organizer=self.request.auth.organizer,
event__live=True,
active=True,
).select_related('event').order_by('first_date')
if self.request.auth.gate:
has_cl = CheckinList.objects.filter(
Q(subevent__isnull=True) | Q(subevent=OuterRef('pk')),
event_id=OuterRef('event_id'),
gates__in=[self.request.auth.gate]
)
qs = qs.annotate(has_cl=Exists(has_cl)).filter(has_cl=True)
return qs
def get(self, request, format=None):
device = request.auth
current_event = None
current_subevent = None
if 'current_event' in request.query_params:
current_event = device.organizer.events.filter(slug=request.query_params['current_event']).first()
if current_event and 'current_subevent' in request.query_params:
current_subevent = current_event.subevents.filter(pk=request.query_params['current_subevent']).first()
if current_event and current_event.has_subevents and not current_subevent:
current_event = None
if current_event:
current_ev = current_subevent or current_event
current_ev_start = current_ev.date_admission or current_ev.date_from
tz = current_event.timezone
if current_ev.date_to and current_ev_start < now() < current_ev.date_to:
# The event that is selected is currently running. Good enough.
return Response(status=status.HTTP_304_NOT_MODIFIED)
# The event that is selected is not currently running. We cannot rely on all events having a proper end date.
# In any case, we'll need to decide between the event that last started (and might still be running) and the
# event that starts next (and might already be letting people in), so let's get these two!
last_started_ev = self.base_event_qs.filter(first_date__lte=now()).last() or self.base_subevent_qs.filter(
first_date__lte=now()).last()
upcoming_event = self.base_event_qs.filter(first_date__gt=now()).first()
upcoming_subevent = self.base_subevent_qs.filter(first_date__gt=now()).first()
if upcoming_event and upcoming_subevent:
if upcoming_event.first_date > upcoming_subevent.first_date:
upcoming_ev = upcoming_subevent
else:
upcoming_ev = upcoming_event
else:
upcoming_ev = upcoming_event or upcoming_subevent
if not upcoming_ev and not last_started_ev:
# Ooops, no events here
return Response(status=status.HTTP_404_NOT_FOUND)
elif upcoming_ev and not last_started_ev:
# No event running, so let's take the next one
return self._suggest_event(current_event, upcoming_ev)
elif last_started_ev and not upcoming_ev:
# No event upcoming, so let's take the next one
return self._suggest_event(current_event, last_started_ev)
if last_started_ev.date_to and now() < last_started_ev.date_to:
# The event that last started is currently running. Good enough.
return self._suggest_event(current_event, last_started_ev)
if not current_event:
tz = (upcoming_event or last_started_ev).timezone
lse_d = last_started_ev.date_from.astimezone(tz).date()
upc_d = upcoming_ev.date_from.astimezone(tz).date()
now_d = now().astimezone(tz).date()
if lse_d == now_d and upc_d != now_d:
# Last event was today, next is tomorrow, stick with today
return self._suggest_event(current_event, last_started_ev)
elif lse_d != now_d and upc_d == now_d:
# Last event was yesterday, next is today, stick with today
return self._suggest_event(current_event, upcoming_ev)
# Both last and next event are today, we switch over in the middle
if now() > last_started_ev.last_date + (upcoming_ev.first_date - last_started_ev.last_date) / 2:
return self._suggest_event(current_event, upcoming_ev)
else:
return self._suggest_event(current_event, last_started_ev)
def _suggest_event(self, current_event, ev):
current_checkinlist = None
if current_event and 'current_checkinlist' in self.request.query_params:
current_checkinlist = current_event.checkin_lists.filter(
pk=self.request.query_params['current_checkinlist']
).first()
if isinstance(ev, SubEvent):
checkinlist_qs = ev.event.checkin_lists.filter(Q(subevent__isnull=True) | Q(subevent=ev))
else:
checkinlist_qs = ev.checkin_lists
if self.request.auth.gate:
checkinlist_qs = checkinlist_qs.filter(gates__in=[self.request.auth.gate])
checkinlist = None
if current_checkinlist:
checkinlist = checkinlist_qs.filter(Q(name=current_checkinlist.name) | Q(pk=current_checkinlist.pk)).first()
if not checkinlist:
checkinlist = checkinlist_qs.first()
r = {
'event': {
'slug': ev.event.slug if isinstance(ev, SubEvent) else ev.slug,
'name': str(ev.event.name) if isinstance(ev, SubEvent) else str(ev.name),
},
'subevent': ev.pk if isinstance(ev, SubEvent) else None,
'checkinlist': checkinlist.pk if checkinlist else None,
}
if r == {
'event': {
'slug': current_event.slug if current_event else None,
'name': str(current_event.name) if current_event else None,
},
'subevent': (
int(self.request.query_params.get('current_subevent'))
if self.request.query_params.get('current_subevent') else None
),
'checkinlist': (
int(self.request.query_params.get('current_checkinlist'))
if self.request.query_params.get('current_checkinlist') else None
),
}:
return Response(status=status.HTTP_304_NOT_MODIFIED)
return Response(r)

View File

@@ -4,7 +4,7 @@ from django.db.models import ProtectedError, Q
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, serializers, views, viewsets
from rest_framework import filters, views, viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -15,7 +15,7 @@ from pretix.api.serializers.event import (
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Device, Event, TaxRule, TeamAPIToken,
CartPosition, Device, Event, ItemCategory, TaxRule, TeamAPIToken,
)
from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts
@@ -73,7 +73,6 @@ class EventViewSet(viewsets.ModelViewSet):
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
lookup_value_regex = '[^/]+'
permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
ordering = ('slug',)
@@ -89,6 +88,7 @@ class EventViewSet(viewsets.ModelViewSet):
)
qs = filter_qs_by_attr(qs, self.request)
return qs.prefetch_related(
'meta_values', 'meta_values__property', 'seat_category_mappings'
)
@@ -193,7 +193,6 @@ with scopes_disabled():
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
class Meta:
model = SubEvent
@@ -229,12 +228,10 @@ with scopes_disabled():
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer
queryset = SubEvent.objects.none()
queryset = ItemCategory.objects.none()
write_permission = 'can_change_event_settings'
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_class = SubEventFilter
ordering = ('date_from',)
ordering_fields = ('id', 'date_from', 'last_modified')
def get_queryset(self):
if getattr(self.request, 'event', None):
@@ -256,20 +253,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
)
def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
resp = self.get_paginated_response(serializer.data)
resp['X-Page-Generated'] = date
return resp
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer)

View File

@@ -3,9 +3,8 @@ import logging
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from oauth2_provider.exceptions import FatalClientError, OAuthToolkitError
from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.forms import AllowForm
from oauth2_provider.settings import oauth2_settings
from oauth2_provider.views import (
AuthorizationView as BaseAuthorizationView,
RevokeTokenView as BaseRevokeTokenView, TokenView as BaseTokenView,
@@ -25,12 +24,9 @@ class OAuthAllowForm(AllowForm):
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
scope = kwargs.pop('scope')
super().__init__(*args, **kwargs)
self.fields['organizers'].queryset = Organizer.objects.filter(
pk__in=user.teams.values_list('organizer', flat=True))
if scope == 'profile':
del self.fields['organizers']
class AuthorizationView(BaseAuthorizationView):
@@ -40,7 +36,6 @@ class AuthorizationView(BaseAuthorizationView):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
kwargs['scope'] = self.request.GET.get('scope')
return kwargs
def get_context_data(self, **kwargs):
@@ -48,14 +43,8 @@ class AuthorizationView(BaseAuthorizationView):
ctx['settings'] = settings
return ctx
def validate_authorization_request(self, request):
require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT)
if require_approval != 'force' and request.GET.get('scope') != 'profile':
raise FatalClientError('Combnination of require_approval and scope values not allowed.')
return super().validate_authorization_request(request)
def create_authorization_response(self, request, scopes, credentials, allow, organizers=None):
credentials["organizers"] = organizers or []
def create_authorization_response(self, request, scopes, credentials, allow, organizers):
credentials["organizers"] = organizers
return super().create_authorization_response(request, scopes, credentials, allow)
def form_valid(self, form):

View File

@@ -26,18 +26,15 @@ from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
OrderPaymentSerializer, OrderPositionSerializer,
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer,
PriceCalcSerializer, SimulatedOrderSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TeamAPIToken, generate_secret,
TeamAPIToken, generate_position_secret, generate_secret,
)
from pretix.base.models.orders import RevokedTicketSecret
from pretix.base.payment import PaymentException
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
@@ -231,7 +228,6 @@ class OrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
order = self.get_object()
send_mail = request.data.get('send_email', True)
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
@@ -273,7 +269,6 @@ class OrderViewSet(viewsets.ModelViewSet):
try:
p.confirm(auth=self.request.auth,
user=self.request.user if request.user.is_authenticated else None,
send_mail=send_mail,
count_waitinglist=False)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -486,9 +481,8 @@ class OrderViewSet(viewsets.ModelViewSet):
order = self.get_object()
order.secret = generate_secret()
for op in order.all_positions.all():
assign_ticket_secret(
request.event, op, force_invalidate=True, save=True
)
op.secret = generate_position_secret()
op.save()
order.save(update_fields=['secret'])
CachedTicket.objects.filter(order_position__order=order).delete()
CachedCombinedTicket.objects.filter(order=order).delete()
@@ -982,7 +976,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
send_mail = request.data.get('send_email', True)
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
@@ -991,7 +984,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
count_waitinglist=False,
send_mail=send_mail,
force=force)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
@@ -1302,26 +1294,3 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
auth=self.request.auth,
)
return Response(status=204)
with scopes_disabled():
class RevokedSecretFilter(FilterSet):
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
class Meta:
model = RevokedTicketSecret
fields = []
class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = RevokedTicketSecretSerializer
queryset = RevokedTicketSecret.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-created',)
ordering_fields = ('created', 'secret')
filterset_class = RevokedSecretFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return RevokedTicketSecret.objects.filter(event=self.request.event)

View File

@@ -6,22 +6,20 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, mixins, serializers, status, viewsets
from rest_framework import filters, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import (
DeviceSerializer, GiftCardSerializer, OrganizerSerializer,
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
TeamMemberSerializer, TeamSerializer,
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
TeamSerializer,
)
from pretix.base.models import (
Device, GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.helpers.dicts import merge_dicts
@@ -31,7 +29,6 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Organizer.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'organizer'
lookup_value_regex = '[^/]+'
filter_backends = (filters.OrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
@@ -355,44 +352,3 @@ class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
serializer = self.get_serializer_class()(instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)
class DeviceViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
GenericViewSet):
serializer_class = DeviceSerializer
queryset = Device.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
lookup_field = 'device_id'
def get_queryset(self):
return self.request.organizer.devices.order_by('pk')
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
inst.log_action(
'pretix.device.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save()
inst.log_action(
'pretix.device.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
return inst

View File

@@ -3,18 +3,14 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.api.auth.permission import ProfilePermission
class MeView(APIView):
authentication_classes = (SessionAuthentication, OAuth2Authentication)
permission_classes = (ProfilePermission,)
def get(self, request, format=None):
return Response({
'email': request.user.email,
'fullname': request.user.fullname,
'locale': request.user.locale,
'is_staff': request.user.is_staff,
'timezone': request.user.timezone
})

View File

@@ -85,8 +85,6 @@ class ParametrizedOrderWebhookEvent(WebhookEvent):
def build_payload(self, logentry: LogEntry):
order = logentry.content_object
if not order:
return None
return {
'notification_id': logentry.pk,
@@ -101,8 +99,6 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
def build_payload(self, logentry: LogEntry):
d = super().build_payload(logentry)
if d is None:
return None
d['orderposition_id'] = logentry.parsed_data.get('position')
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
d['checkin_list'] = logentry.parsed_data.get('list')
@@ -222,10 +218,6 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
if payload is None:
# Content object deleted?
return
t = time.time()
try:
@@ -250,7 +242,7 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
@@ -262,6 +254,6 @@ def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2)) # max is 2 ** (8*2) = 65536 seconds = ~18 hours
raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError:
pass

View File

@@ -98,10 +98,7 @@ class BaseAuthBackend:
class NativeAuthBackend(BaseAuthBackend):
identifier = 'native'
@property
def verbose_name(self):
return _('{system} User').format(system=settings.PRETIX_INSTANCE_NAME)
verbose_name = _('pretix User')
@property
def login_form_fields(self) -> dict:

View File

@@ -114,7 +114,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
'site_url': settings.SITE_URL,
'body': body_md,
'subject': str(subject),
'color': settings.PRETIX_PRIMARY_COLOR,
'color': '#8E44B3',
'rtl': get_language() in settings.LANGUAGES_RTL
}
if self.event:
@@ -222,7 +222,6 @@ class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
base_parameters.append('position_or_address')
params = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
@@ -241,9 +240,7 @@ def get_email_context(**kwargs):
try:
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
finally:
kwargs.setdefault("position_or_address", kwargs['invoice_address'])
kwargs['invoice_address'] = InvoiceAddress()
ctx = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
@@ -271,8 +268,7 @@ def get_best_name(position_or_address, parts=False):
if isinstance(position_or_address, InvoiceAddress):
if position_or_address.name:
return position_or_address.name_parts if parts else position_or_address.name
elif position_or_address.order:
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
if isinstance(position_or_address, OrderPosition):
if position_or_address.attendee_name:

View File

@@ -388,12 +388,6 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'),
_('Voucher'),
_('Pseudonymization ID'),
_('Seat ID'),
_('Seat name'),
_('Seat zone'),
_('Seat row'),
_('Seat number'),
_('Order comment'),
]
questions = list(Question.objects.filter(event__in=self.events))
@@ -477,19 +471,6 @@ class OrderListExporter(MultiSheetListExporter):
op.voucher.code if op.voucher else '',
op.pseudonymization_id,
]
if op.seat:
row += [
op.seat.seat_guid,
str(op.seat),
op.seat.zone_name,
op.seat.row_name,
op.seat.seat_number,
]
else:
row += ['', '', '', '', '']
row.append(order.comment)
acache = {}
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead
@@ -530,7 +511,7 @@ class OrderListExporter(MultiSheetListExporter):
row += [''] * (8 + (len(name_scheme['fields']) if name_scheme and len(name_scheme['fields']) > 1 else 0))
row += [
order.sales_channel,
order.locale,
order.locale
]
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
@@ -664,13 +645,11 @@ class GiftcardRedemptionListExporter(ListExporter):
def iterate_list(self, form_data):
payments = OrderPayment.objects.filter(
order__event__in=self.events,
provider='giftcard',
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
provider='giftcard'
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event__in=self.events,
provider='giftcard',
state=OrderRefund.REFUND_STATE_DONE
provider='giftcard'
).order_by('created')
objs = sorted(list(payments) + list(refunds), key=lambda o: (o.order.code, o.created))

View File

@@ -1,12 +1,17 @@
import hashlib
import ipaddress
from django import forms
from django.conf import settings
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.http import get_client_ip
class LoginForm(forms.Form):
@@ -18,6 +23,7 @@ class LoginForm(forms.Form):
error_messages = {
'invalid_login': _("This combination of credentials is not known to our system."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'inactive': _("This account is inactive.")
}
@@ -39,10 +45,36 @@ class LoginForm(forms.Form):
else:
move_to_end(self.fields, 'keep_logged_in')
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'

View File

@@ -36,8 +36,8 @@ from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS,
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import ExtFileField, SplitDateTimeField
@@ -49,7 +49,7 @@ from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__)
REQUIRED_NAME_PARTS = ['salutation', 'given_name', 'family_name', 'full_name']
REQUIRED_NAME_PARTS = ['given_name', 'family_name', 'full_name']
class NamePartsWidget(forms.MultiWidget):
@@ -73,8 +73,6 @@ class NamePartsWidget(forms.MultiWidget):
a['data-fname'] = fname
if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
elif fname == 'salutation':
widgets.append(Select(attrs=a, choices=[('', '---')] + [(s, s) for s in PERSON_NAME_SALUTATIONS]))
else:
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs)
@@ -164,18 +162,12 @@ class NamePartsFormField(forms.MultiValueField):
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
)
elif fname == 'salutation':
d = dict(defaults)
d.pop('max_length', None)
field = forms.ChoiceField(
**d,
choices=[('', '---')] + [(s, s) for s in PERSON_NAME_SALUTATIONS]
)
field.part_name = fname
fields.append(field)
else:
field = forms.CharField(**defaults)
field.part_name = fname
fields.append(field)
field.part_name = fname
fields.append(field)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)

View File

@@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
@@ -19,6 +20,7 @@ class UserSettingsForm(forms.ModelForm):
"address or password."),
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
old_pw = forms.CharField(max_length=255,
@@ -64,6 +66,18 @@ class UserSettingsForm(forms.ModelForm):
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
if old_pw and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if old_pw and not check_password(old_pw, self.user.password):
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],

View File

@@ -396,13 +396,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(show_times=False),
to_date=self.invoice.event.get_date_to_display(show_times=False)
from_date=self.invoice.event.get_date_from_display(),
to_date=self.invoice.event.get_date_to_display()
)
)
else:
p_str = (
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display()
)
else:
p_str = shorten(self.invoice.event.name)

View File

@@ -13,10 +13,6 @@ from ...signals import periodic_task
class Command(BaseCommand):
help = "Run periodic tasks"
def add_arguments(self, parser):
parser.add_argument('--tasks', action='store', type=str, help='Only execute the tasks with this name '
'(dotted path, comma separation)')
def handle(self, *args, **options):
verbosity = int(options['verbosity'])
@@ -24,13 +20,8 @@ class Command(BaseCommand):
return
for receiver in periodic_task._live_receivers(self):
name = f'{receiver.__module__}.{receiver.__name__}'
if options.get('tasks'):
if name not in options.get('tasks').split(','):
continue
if verbosity > 1:
self.stdout.write(f'Running {name}')
self.stdout.write(f'Running {receiver.__module__}.{receiver.__name__}')
t0 = time.time()
try:
r = receiver(signal=periodic_task, sender=self)
@@ -47,6 +38,6 @@ class Command(BaseCommand):
else:
if options.get('verbosity') > 1:
if r is SKIPPED:
self.stdout.write(self.style.SUCCESS(f'Skipped {name}'))
self.stdout.write(self.style.SUCCESS(f'Skipped {receiver.__module__}.{receiver.__name__}'))
else:
self.stdout.write(self.style.SUCCESS(f'Completed {name} in {round(time.time() - t0, 3)}s'))
self.stdout.write(self.style.SUCCESS(f'Completed {receiver.__module__}.{receiver.__name__} in {round(time.time() - t0, 3)}s'))

View File

@@ -3,8 +3,7 @@ from urllib.parse import urlsplit
import pytz
from django.conf import settings
from django.http import HttpRequest, HttpResponse, Http404
from django.middleware.common import CommonMiddleware
from django.http import HttpRequest, HttpResponse
from django.urls import get_script_prefix
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
@@ -253,15 +252,3 @@ class SecurityMiddleware(MiddlewareMixin):
del resp['Content-Security-Policy']
return resp
class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request):
"""
Raise an error regardless of DEBUG mode when in POST, PUT, or PATCH.
"""
new_path = super().get_full_path_with_slash(request)
if request.method in ('POST', 'PUT', 'PATCH'):
raise Http404('Please append a / at the end of the URL')
return new_path

View File

@@ -482,7 +482,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='orderposition',
name='secret',
field=models.CharField(db_index=True, default="invalid", max_length=64),
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
),
migrations.AddField(
model_name='order',

View File

@@ -17,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='orderposition',
name='secret',
field=models.CharField(default="invalid", max_length=64),
field=models.CharField(default=pretix.base.models.orders.generate_position_secret, max_length=64),
),
]

View File

@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='orderposition',
name='secret',
field=models.CharField(db_index=True, default="invalid", max_length=64),
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
),
migrations.AlterField(
model_name='voucher',

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.0.11 on 2020-12-18 18:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0162_remove_seat_name'),
]
operations = [
migrations.AddField(
model_name='cachedfile',
name='session_key',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='cachedfile',
name='web_download',
field=models.BooleanField(default=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.0.9 on 2020-10-13 08:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0162_remove_seat_name'),
]
operations = [
migrations.AddField(
model_name='device',
name='security_profile',
field=models.CharField(default='full', max_length=190, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.0.9 on 2020-10-15 16:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0163_device_security_profile'),
]
operations = [
migrations.AddField(
model_name='subevent',
name='last_modified',
field=models.DateTimeField(auto_now=True, db_index=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-15 19:24
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0164_subevent_last_modified'),
]
operations = [
migrations.AlterField(
model_name='orderposition',
name='secret',
field=models.CharField(db_index=True, max_length=64),
),
migrations.CreateModel(
name='RevokedTicketSecret',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('secret', models.TextField(db_index=True)),
('created', models.DateTimeField(auto_now_add=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revoked_secrets', to='pretixbase.Event')),
('position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revoked_secrets', to='pretixbase.OrderPosition')),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-15 20:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0165_auto_20201015_1924'),
]
operations = [
migrations.AlterField(
model_name='orderposition',
name='secret',
field=models.CharField(db_index=True, max_length=255),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-20 06:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0166_auto_20201015_2029'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='exit_all_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 3.0.9 on 2020-10-23 14:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0167_checkinlist_exit_all_at'),
]
operations = [
migrations.CreateModel(
name='Gate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('identifier', models.CharField(max_length=190)),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='gates', to='pretixbase.Organizer')),
],
),
migrations.AddField(
model_name='checkin',
name='gate',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='checkins', to='pretixbase.Gate'),
),
migrations.AddField(
model_name='device',
name='gate',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='pretixbase.Gate'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-24 15:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0168_auto_20201023_1447'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='gates',
field=models.ManyToManyField(to='pretixbase.Gate'),
),
]

View File

@@ -1,49 +0,0 @@
# Generated by Django 3.0.10 on 2020-10-30 21:09
import json
from django.db import migrations
def migrate_tax_rules(apps, schema_editor):
TaxRule = apps.get_model('pretixbase', 'TaxRule')
for tr in TaxRule.objects.filter(eu_reverse_charge=True):
if tr.custom_rules and tr.custom_rules != '[]':
# Custom rules take precedence
continue
r = [{
'country': str(tr.home_country),
'address_type': '',
'action': 'vat'
}, {
'country': 'EU',
'address_type': 'business_vat_id',
'action': 'reverse'
}, {
'country': 'EU',
'address_type': '',
'action': 'vat'
}, {
'country': 'ZZ',
'address_type': '',
'action': 'no'
}]
tr.custom_rules = json.dumps(r)
tr.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0169_checkinlist_gates'),
]
operations = [
migrations.RunPython(migrate_tax_rules, migrations.RunPython.noop),
migrations.RemoveField(
model_name='taxrule',
name='eu_reverse_charge',
),
migrations.RemoveField(
model_name='taxrule',
name='home_country',
),
]

View File

@@ -2,7 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
from .auth import U2FDevice, User, WebAuthnDevice
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .devices import Device, Gate
from .devices import Device
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
@@ -19,8 +19,8 @@ from .notifications import NotificationSetting
from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
QuestionAnswer, RevokedTicketSecret, cachedcombinedticket_name,
cachedticket_name, generate_position_secret, generate_secret,
QuestionAnswer, cachedcombinedticket_name, cachedticket_name,
generate_position_secret, generate_secret,
)
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,

View File

@@ -28,6 +28,8 @@ class CachedFile(models.Model):
filename = models.CharField(max_length=255)
type = models.CharField(max_length=255)
file = models.FileField(null=True, blank=True, upload_to=cachedfile_name, max_length=255)
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
session_key = models.TextField(null=True, blank=True) # only allow download in this session
@receiver(post_delete, sender=CachedFile)

View File

@@ -21,11 +21,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.'))
gates = models.ManyToManyField(
'Gate', verbose_name=_("Gates"), blank=True,
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
"check-in devices.")
)
allow_entry_after_exit = models.BooleanField(
verbose_name=_('Allow re-entering after an exit scan'),
default=True
@@ -35,10 +30,7 @@ class CheckinList(LoggedModel):
help_text=_('Use this option to turn off warnings if a ticket is scanned a second time.'),
default=False
)
exit_all_at = models.DateTimeField(
verbose_name=_('Automatically check out everyone at'),
null=True, blank=True
)
auto_checkin_sales_channels = MultiStringField(
default=[],
blank=True,
@@ -70,7 +62,7 @@ class CheckinList(LoggedModel):
return qs
@property
def positions_inside(self):
def inside_count(self):
return self.positions.annotate(
last_entry=Subquery(
Checkin.objects.filter(
@@ -95,11 +87,7 @@ class CheckinList(LoggedModel):
& Q(
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
)
)
@property
def inside_count(self):
return self.positions_inside.count()
).count()
@property
@scopes_disabled()
@@ -164,9 +152,6 @@ class Checkin(models.Model):
device = models.ForeignKey(
'pretixbase.Device', related_name='checkins', on_delete=models.PROTECT, null=True, blank=True
)
gate = models.ForeignKey(
'pretixbase.Gate', related_name='checkins', on_delete=models.SET_NULL, null=True, blank=True
)
auto_checked_in = models.BooleanField(default=False)
objects = ScopedManager(organizer='position__order__event__organizer')

View File

@@ -1,13 +1,11 @@
import string
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Max
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES
from pretix.base.models import LoggedModel
@@ -35,64 +33,12 @@ def generate_api_token():
return token
class Gate(LoggedModel):
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.PROTECT,
related_name='gates'
)
name = models.CharField(
verbose_name=_("Name"),
max_length=190,
)
identifier = models.CharField(
max_length=190, blank=True,
verbose_name=_("Internal identifier"),
help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do '
'not input one, we will generate one automatically.')
)
class Meta:
ordering = ('name',)
def __str__(self):
return self.name
def clean_identifier(self, code):
Gate._clean_identifier(self.organizer, code, self)
@staticmethod
def _clean_identifier(organizer, code, instance=None):
qs = Gate.objects.filter(organizer=organizer, identifier__iexact=code)
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
raise ValidationError(_('This identifier is already used for a different question.'))
def save(self, *args, **kwargs):
if not self.identifier:
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=8, allowed_chars=charset)
if not Gate.objects.filter(organizer=self.organizer, identifier=code).exists():
self.identifier = code
break
return super().save(*args, **kwargs)
class Device(LoggedModel):
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.PROTECT,
related_name='devices'
)
gate = models.ForeignKey(
'pretixbase.Gate',
verbose_name=_('Gate'),
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='devices'
)
device_id = models.PositiveIntegerField()
unique_serial = models.CharField(max_length=190, default=generate_serial, unique=True)
initialization_token = models.CharField(max_length=190, default=generate_initialization_token, unique=True)
@@ -128,13 +74,6 @@ class Device(LoggedModel):
max_length=190,
null=True, blank=True
)
security_profile = models.CharField(
max_length=190,
choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()],
default='full',
null=True,
blank=False
)
objects = ScopedManager(organizer='organizer')

View File

@@ -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, Value
from django.db.models import Exists, 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
@@ -89,7 +89,7 @@ class EventMixin:
self.date_from.astimezone(tz), "TIME_FORMAT"
)
def get_date_to_display(self, tz=None, show_times=True, short=False) -> str:
def get_date_to_display(self, tz=None, short=False) -> str:
"""
Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting. Returns an empty string
@@ -100,14 +100,14 @@ class EventMixin:
return ""
return _date(
self.date_to.astimezone(tz),
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT")
)
def get_date_range_display(self, tz=None, force_show_end=False) -> str:
"""
Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_date_to``
setting. Times are not shown.
of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings.
"""
tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
@@ -189,9 +189,7 @@ class EventMixin:
).order_by().values_list('quotas__pk').annotate(
items=GroupConcat('pk', delimiter=',')
).values('items')
return qs.annotate(
has_paid_item=Exists(Item.objects.filter(event_id=OuterRef(cls._event_id), default_price__gt=0))
).prefetch_related(
return qs.prefetch_related(
Prefetch(
'quotas',
to_attr='active_quotas',
@@ -282,7 +280,6 @@ class Event(EventMixin, LoggedModel):
"""
settings_namespace = 'event'
_event_id = 'pk'
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
testmode = models.BooleanField(default=False)
@@ -662,14 +659,7 @@ class Event(EventMixin, LoggedModel):
s.product = item_map[s.product_id]
s.save()
skip_settings = (
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
)
for s in other.settings._objects.all():
if s.key in skip_settings:
continue
s.object = self
s.pk = None
if s.value.startswith('file://'):
@@ -761,31 +751,6 @@ class Event(EventMixin, LoggedModel):
renderers[pp.identifier] = pp
return renderers
@cached_property
def ticket_secret_generators(self) -> dict:
"""
Returns a dictionary of cached initialized ticket secret generators mapped by their identifiers.
"""
from ..signals import register_ticket_secret_generators
responses = register_ticket_secret_generators.send(self)
renderers = {}
for receiver, response in responses:
if not isinstance(response, list):
response = [response]
for p in response:
pp = p(self)
renderers[pp.identifier] = pp
return renderers
@property
def ticket_secret_generator(self):
"""
Returns the currently configured ticket secret generator.
"""
tsgs = self.ticket_secret_generators
return tsgs[self.settings.ticket_secret_generator]
def get_data_shredders(self) -> dict:
"""
Returns a dictionary of initialized data shredders mapped by their identifiers.
@@ -821,12 +786,7 @@ class Event(EventMixin, LoggedModel):
'name_ascending': ('name', 'date_from'),
'name_descending': ('-name', 'date_from'),
}[ordering]
subevs = queryset.annotate(
has_paid_item=Value(
self.cache.get_or_set('has_paid_item', lambda: self.items.filter(default_price__gt=0).exists(), 3600),
output_field=models.BooleanField()
)
).filter(
subevs = queryset.filter(
Q(active=True) & Q(is_public=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
| Q(date_to__gte=now() - timedelta(hours=24))
@@ -1020,7 +980,6 @@ class SubEvent(EventMixin, LoggedModel):
:type location: str
"""
_event_id = 'event_id'
event = models.ForeignKey(Event, related_name="subevents", on_delete=models.PROTECT)
active = models.BooleanField(default=False, verbose_name=_("Active"),
help_text=_("Only with this checkbox enabled, this date is visible in the "
@@ -1068,9 +1027,6 @@ class SubEvent(EventMixin, LoggedModel):
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
last_modified = models.DateTimeField(
auto_now=True, db_index=True
)
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')

View File

@@ -1140,8 +1140,6 @@ class Question(LoggedModel):
return None
if self.type == Question.TYPE_CHOICE:
if isinstance(answer, QuestionOption):
return answer
q = Q(identifier=answer)
if isinstance(answer, int) or answer.isdigit():
q |= Q(pk=answer)
@@ -1156,8 +1154,6 @@ class Question(LoggedModel):
Q(identifier__in=answer.split(","))
))
llen = len(answer.split(','))
elif all(isinstance(o, QuestionOption) for o in answer):
return o
else:
l_ = list(self.options.filter(
Q(pk__in=[a for a in answer if isinstance(a, int) or a.isdigit()]) |

View File

@@ -57,7 +57,8 @@ def generate_secret():
def generate_position_secret():
raise TypeError("Function no longer exists, use secret generators")
# Exclude o,0,1,i,l to avoid confusion with bad fonts/printers
return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789')
class Order(LockModel, LoggedModel):
@@ -1937,7 +1938,7 @@ class OrderPosition(AbstractPosition):
max_digits=10, decimal_places=2,
verbose_name=_('Tax value')
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
pseudonymization_id = models.CharField(
max_length=16,
@@ -2030,18 +2031,13 @@ class OrderPosition(AbstractPosition):
self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs):
from pretix.base.secrets import assign_ticket_secret
if self.tax_rate is None:
self._calculate_tax()
self.order.touch()
if not self.pk:
while not self.secret or OrderPosition.all.filter(
secret=self.secret, order__event__organizer_id=self.order.event.organizer_id
).exists():
assign_ticket_secret(
event=self.order.event, position=self, force_invalidate=True, save=False
)
while OrderPosition.all.filter(secret=self.secret,
order__event__organizer_id=self.order.event.organizer_id).exists():
self.secret = generate_position_secret()
if not self.pseudonymization_id:
self.assign_pseudonymization_id()
@@ -2071,7 +2067,7 @@ class OrderPosition(AbstractPosition):
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False):
"""
Sends an email to the attendee. Basically, this method does two things:
Sends an email to the user that placed this order. Basically, this method does two things:
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
``order`` parameters.
@@ -2330,18 +2326,6 @@ class CancellationRequest(models.Model):
refund_as_giftcard = models.BooleanField(default=False)
class RevokedTicketSecret(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='revoked_secrets')
position = models.ForeignKey(
OrderPosition,
on_delete=models.SET_NULL,
related_name='revoked_secrets',
null=True,
)
secret = models.TextField(db_index=True)
created = models.DateTimeField(auto_now_add=True)
@receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs):
if instance.file:

View File

@@ -1,6 +1,7 @@
import json
from decimal import Decimal
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 _
@@ -9,6 +10,7 @@ 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:
@@ -105,6 +107,21 @@ class TaxRule(LoggedModel):
verbose_name=_("The configured product prices include the tax amount"),
default=True,
)
eu_reverse_charge = models.BooleanField(
verbose_name=_("Use EU reverse charge taxation rules"),
default=False,
help_text=_("Not recommended. Most events will NOT be qualified for reverse charge since the place of "
"taxation is the location of the event. This option disables charging VAT for all customers "
"outside the EU and for business customers in different EU countries who entered a valid EU VAT "
"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(
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, '
'if configured above.'),
)
custom_rules = models.TextField(blank=True, null=True)
class Meta:
@@ -130,13 +147,17 @@ class TaxRule(LoggedModel):
eu_reverse_charge=False
)
def clean(self):
if self.eu_reverse_charge and not self.home_country:
raise ValidationError(_('You need to set your home country to use the reverse charge feature.'))
def __str__(self):
if self.price_includes_tax:
s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name)
else:
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
if self.has_custom_rules:
s += ' ({})'.format(_('with custom rules'))
if self.eu_reverse_charge:
s += ' ({})'.format(_('reverse charge enabled'))
return str(s)
@property
@@ -153,7 +174,7 @@ class TaxRule(LoggedModel):
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'), gross_price_is_tax_rate: Decimal = None):
subtract_from_gross=Decimal('0.00')):
from .event import Event
try:
currency = currency or self.event.currency
@@ -165,9 +186,7 @@ class TaxRule(LoggedModel):
rate = override_tax_rate
elif invoice_address:
adjust_rate = self.tax_rate_for(invoice_address)
if adjust_rate == gross_price_is_tax_rate and base_price_is == 'gross':
rate = adjust_rate
elif adjust_rate != rate:
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'
@@ -237,12 +256,50 @@ class TaxRule(LoggedModel):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule['action'] == 'reverse'
if not self.eu_reverse_charge:
return False
if not invoice_address or not invoice_address.country:
return False
if str(invoice_address.country) not in EU_COUNTRIES:
return False
if invoice_address.country == self.home_country:
return False
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
return True
return False
def _tax_applicable(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return True
if not invoice_address or not invoice_address.country:
# No country specified? Always apply VAT!
return True
if str(invoice_address.country) not in EU_COUNTRIES:
# Non-EU country? Never apply VAT!
return False
if invoice_address.country == self.home_country:
# Within same EU country? Always apply VAT!
return True
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
# Reverse charge case
return False
# Consumer in different EU country / invalid VAT
return True
def delete(self, *args, **kwargs):

View File

@@ -1,5 +1,4 @@
import re
from collections import defaultdict
from decimal import Decimal, DecimalException
import pycountry
@@ -13,13 +12,11 @@ from django.utils.translation import (
)
from django_countries import countries
from django_countries.fields import Country
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country
from pretix.base.models import (
ItemVariation, OrderPosition, Question, QuestionAnswer, QuestionOption,
Seat,
ItemVariation, OrderPosition, QuestionAnswer, QuestionOption, Seat,
)
from pretix.base.services.pricing import get_price
from pretix.base.settings import (
@@ -420,7 +417,7 @@ class AttendeeStreet(ImportColumn):
return _('Attendee address') + ': ' + _('Address')
def assign(self, value, order, position, invoice_address, **kwargs):
position.street = value or ''
position.address = value or ''
class AttendeeZip(ImportColumn):
@@ -531,7 +528,7 @@ class Secret(ImportColumn):
super().__init__(*args)
def clean(self, value, previous_values):
if value and (value in self._cached or OrderPosition.all.filter(order__event__organizer=self.event.organizer, secret=value).exists()):
if value and (value in self._cached or OrderPosition.all.filter(order__event=self.event, secret=value).exists()):
raise ValidationError(
_('You cannot assign a position secret that already exists.')
)
@@ -629,22 +626,6 @@ class Comment(ImportColumn):
class QuestionColumn(ImportColumn):
def __init__(self, event, q):
self.q = q
self.option_resolve_cache = defaultdict(set)
for opt in q.options.all():
self.option_resolve_cache[str(opt.id)].add(opt)
self.option_resolve_cache[opt.identifier].add(opt)
if isinstance(opt.answer, LazyI18nString):
if isinstance(opt.answer.data, dict):
for v in opt.answer.data.values():
self.option_resolve_cache[v.strip()].add(opt)
else:
self.option_resolve_cache[opt.answer.data.strip()].add(opt)
else:
self.option_resolve_cache[opt.answer.strip()].add(opt)
super().__init__(event)
@property
@@ -657,23 +638,7 @@ class QuestionColumn(ImportColumn):
def clean(self, value, previous_values):
if value:
if self.q.type == Question.TYPE_CHOICE:
if value not in self.option_resolve_cache:
raise ValidationError(_('Invalid option selected.'))
if len(self.option_resolve_cache[value]) > 1:
raise ValidationError(_('Ambiguous option selected.'))
return list(self.option_resolve_cache[value])[0]
elif self.q.type == Question.TYPE_CHOICE_MULTIPLE:
values = value.split(',')
if any(v.strip() not in self.option_resolve_cache for v in values):
raise ValidationError(_('Invalid option selected.'))
if any(len(self.option_resolve_cache[v.strip()]) > 1 for v in values):
raise ValidationError(_('Ambiguous option selected.'))
return [list(self.option_resolve_cache[v.strip()])[0] for v in values]
else:
return self.q.clean_answer(value)
return self.q.clean_answer(value)
def assign(self, value, order, position, invoice_address, **kwargs):
if value:
@@ -737,7 +702,7 @@ def get_all_columns(event):
SeatColumn(event),
Comment(event)
]
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
for q in event.questions.exclude(type='F'):
default.append(QuestionColumn(event, q))
for recv, resp in order_import_columns.send(sender=event):

View File

@@ -717,7 +717,7 @@ class BasePaymentProvider:
The default implementation returns an empty string.
:param refund: The refund object
:param order: The order object
"""
return ''
@@ -1134,7 +1134,7 @@ class GiftCardPayment(BasePaymentProvider):
cart['raw']
)
total += sum([f.value for f in fees])
remainder = total
remainder = total - gc.value
if remainder > Decimal('0.00'):
del cs['payment']
messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format(

View File

@@ -48,9 +48,7 @@ DEFAULT_VARIABLES = OrderedDict((
("secret", {
"label": _("Ticket code (barcode content)"),
"editor_sample": "tdmruoekvkpbv1o2mv8xccvqcikvr58u",
"evaluate": lambda orderposition, order, event: (
orderposition.secret[:30] + "" if len(orderposition.secret) > 32 else orderposition.secret
)
"evaluate": lambda orderposition, order, event: orderposition.secret
}),
("order", {
"label": _("Order code"),
@@ -429,13 +427,8 @@ class Renderer:
elif content == 'pseudonymization_id':
content = op.pseudonymization_id
level = 'H'
if len(content) > 32:
level = 'M'
if len(content) > 128:
level = 'L'
reqs = float(o['size']) * mm
qrw = QrCodeWidget(content, barLevel=level, barHeight=reqs, barWidth=reqs)
qrw = QrCodeWidget(content, barLevel='H', barHeight=reqs, barWidth=reqs)
d = Drawing(reqs, reqs)
d.add(qrw)
qr_x = float(o['left']) * mm

View File

@@ -1,11 +0,0 @@
syntax = "proto3";
option java_package = "eu.pretix.libpretixsync.crypto.sig1";
option java_outer_classname = "TicketProtos";
message Ticket {
string seed = 1;
int64 item = 2;
int64 variation = 3;
int64 subevent = 4;
}

View File

@@ -1,93 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: pretix_sig1.proto
from google.protobuf import (
descriptor as _descriptor, message as _message, reflection as _reflection,
symbol_database as _symbol_database,
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor.FileDescriptor(
name='pretix_sig1.proto',
package='',
syntax='proto3',
serialized_options=b'\n\026eu.pretix.secrets.sig1B\014TicketProtos',
create_key=_descriptor._internal_create_key,
serialized_pb=b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42&\n\x16\x65u.pretix.secrets.sig1B\x0cTicketProtosb\x06proto3'
)
_TICKET = _descriptor.Descriptor(
name='Ticket',
full_name='Ticket',
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name='seed', full_name='Ticket.seed', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=b"".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='item', full_name='Ticket.item', index=1,
number=2, type=3, cpp_type=2, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='variation', full_name='Ticket.variation', index=2,
number=3, type=3, cpp_type=2, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='subevent', full_name='Ticket.subevent', index=3,
number=4, type=3, cpp_type=2, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=21,
serialized_end=94,
)
DESCRIPTOR.message_types_by_name['Ticket'] = _TICKET
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
Ticket = _reflection.GeneratedProtocolMessageType('Ticket', (_message.Message,), {
'DESCRIPTOR' : _TICKET,
'__module__' : 'pretix_sig1_pb2'
# @@protoc_insertion_point(class_scope:Ticket)
})
_sym_db.RegisterMessage(Ticket)
DESCRIPTOR._options = None
# @@protoc_insertion_point(module_scope)

View File

@@ -1,202 +0,0 @@
import base64
import struct
from cryptography.hazmat.backends.openssl.backend import Backend
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization.base import (
Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key,
load_pem_public_key,
)
from django.conf import settings
from django.dispatch import receiver
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Item, ItemVariation, SubEvent
from pretix.base.secretgenerators import pretix_sig1_pb2
from pretix.base.signals import register_ticket_secret_generators
class BaseTicketSecretGenerator:
"""
This is the base class to be used for all ticket secret generators.
"""
@property
def verbose_name(self) -> str:
"""
A human-readable name for this generator. This should be short but self-explanatory.
"""
raise NotImplementedError() # NOQA
@property
def identifier(self) -> str:
"""
A short and unique identifier for this renderer. This should only contain lowercase letters
and in most cases will be the same as your package name.
"""
raise NotImplementedError() # NOQA
def __init__(self, event):
self.event = event
@property
def use_revocation_list(self):
"""
If this attribute is set to ``True``, the system will set all no-longer-used secrets on a revocation list.
This is not required for pretix' default method of just using random identifiers as ticket secrets
since all ticket scans will be compared to the database. However, if your secret generation method
is designed to allow offline verification without a ticket database, all invalidated/replaced
secrets as well as all secrets of canceled tickets will need to go to a revocation list.
"""
return False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False) -> str:
"""
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
and the current secret ``current_secret`` (if any).
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
The algorithm is expected to conform to the following rules:
If ``force_invalidate`` is set to ``True``, the method MUST return a different secret than ``current_secret``,
such that ``current_secret`` can get revoked.
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have the same value
as when ``current_secret`` was generated, then this method MUST return ``current_secret`` unchanged.
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have a different value
as when ``current_secret`` was generated, then this method MAY OR MAY NOT return ``current_secret`` unchanged,
depending on the semantics of the method.
"""
raise NotImplementedError()
class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
verbose_name = _('Random (default, works with all pretix apps)')
identifier = 'random'
use_revocation_list = False
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False):
if current_secret and not force_invalidate:
return current_secret
return get_random_string(
length=settings.ENTROPY['ticket_secret'],
# Exclude o,0,1,i,l to avoid confusion with bad fonts/printers
allowed_chars='abcdefghjkmnpqrstuvwxyz23456789'
)
class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
"""
Secret generator for signed QR codes.
QR-code format:
- 1 Byte with the version of the scheme, currently 0x01
- 2 Bytes length of the payload (big-endian) => n
- 2 Bytes length of the signature (big-endian) => m
- n Bytes payload (with protobuf encoding)
- m Bytes ECDSA signature of Sign(payload)
The resulting string is REVERSED, to avoid all secrets of same length beginning with the same 10
characters, which would make it impossible to search for secrets manually.
"""
verbose_name = _('pretix signature scheme 1 (for very large events, does not work with pretixSCAN on iOS and '
'changes semantics of offline scanning please refer to documentation or support for details)')
identifier = 'pretix_sig1'
use_revocation_list = True
def _generate_keys(self):
privkey = Ed25519PrivateKey.generate()
pubkey = privkey.public_key()
self.event.settings.ticket_secrets_pretix_sig1_privkey = base64.b64encode(privkey.private_bytes(
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
)).decode()
self.event.settings.ticket_secrets_pretix_sig1_pubkey = base64.b64encode(pubkey.public_bytes(
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
)).decode()
def _sign_payload(self, payload):
if not self.event.settings.ticket_secrets_pretix_sig1_privkey:
self._generate_keys()
privkey = load_pem_private_key(
base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_privkey), None, Backend()
)
signature = privkey.sign(payload)
return (
bytes([0x01])
+ struct.pack(">H", len(payload))
+ struct.pack(">H", len(signature))
+ payload
+ signature
)
def _parse(self, secret):
try:
rawbytes = base64.b64decode(secret[::-1])
if rawbytes[0] != 1:
raise ValueError('Invalid version')
payload_len = struct.unpack(">H", rawbytes[1:3])[0]
sig_len = struct.unpack(">H", rawbytes[3:5])[0]
payload = rawbytes[5:5 + payload_len]
signature = rawbytes[5 + payload_len:5 + payload_len + sig_len]
pubkey = load_pem_public_key(
base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_pubkey), Backend()
)
pubkey.verify(signature, payload)
t = pretix_sig1_pb2.Ticket()
t.ParseFromString(payload)
return t
except:
return None
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
current_secret: str = None, force_invalidate=False):
if current_secret and not force_invalidate:
ticket = self._parse(current_secret)
if ticket:
unchanged = (
ticket.item == item.pk and
ticket.variation == (variation.pk if variation else 0) and
ticket.subevent == (subevent.pk if subevent else 0)
)
if unchanged:
return current_secret
t = pretix_sig1_pb2.Ticket()
t.seed = get_random_string(9)
t.item = item.pk
t.variation = variation.pk if variation else 0
t.subevent = subevent.pk if subevent else 0
payload = t.SerializeToString()
result = base64.b64encode(self._sign_payload(payload)).decode()[::-1]
return result
@receiver(register_ticket_secret_generators, dispatch_uid="ticket_generator_default")
def recv_classic(sender, **kwargs):
return [RandomTicketSecretGenerator, Sig1TicketSecretGenerator]
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True):
gen = event.ticket_secret_generator
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
force_invalidate = True
secret = gen.generate_secret(
item=position.item,
variation=position.variation,
subevent=position.subevent,
current_secret=position.secret,
force_invalidate=force_invalidate
)
changed = position.secret != secret
if position.secret and changed and gen.use_revocation_list:
position.revoked_secrets.create(event=event, secret=position.secret)
position.secret = secret
if save and changed:
position.save()

View File

@@ -65,7 +65,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = str(subject).format_map(TolerantDict(email_context))
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
email_context = get_email_context(event_or_subevent=subevent or order.event,
event=order.event,
refund_amount=refund_amount,
position_or_address=p,
@@ -82,12 +82,11 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
@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_per_ticket: str, keep_fee_percentage: str, keep_fees: list=None,
manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None,
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,
send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={},
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None,
subevents_from: str=None, subevents_to: str=None):
user: int=None, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None):
send_subject = LazyI18nString(send_subject)
send_message = LazyI18nString(send_message)
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
@@ -103,20 +102,14 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
pcnt__gt=0
).all()
if subevent or subevents_from:
if subevent:
subevents = event.subevents.filter(pk=subevent)
subevent = subevents.first()
subevent_ids = {subevent.pk}
else:
subevents = event.subevents.filter(date_from__gte=subevents_from, date_from__lt=subevents_to)
subevent_ids = set(subevents.values_list('id', flat=True))
if subevent:
subevent = event.subevents.get(pk=subevent)
has_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).filter(
subevent__in=subevents
subevent=subevent
)
has_other_subevent = OrderPosition.objects.filter(order_id=OuterRef('pk')).exclude(
subevent__in=subevents
subevent=subevent
)
orders_to_change = orders_to_cancel.annotate(
has_subevent=Exists(has_subevent),
@@ -131,18 +124,15 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
has_subevent=True, has_other_subevent=False
)
for se in subevents:
se.log_action(
'pretix.subevent.canceled', user=user,
)
se.active = False
se.save(update_fields=['active'])
se.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
subevent.log_action(
'pretix.subevent.canceled', user=user,
)
subevent.active = False
subevent.save(update_fields=['active'])
subevent.log_action(
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
)
else:
subevents = None
subevent_ids = set()
orders_to_change = event.orders.none()
event.log_action(
'pretix.event.canceled', user=user,
@@ -156,9 +146,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
)
failed = 0
total = orders_to_cancel.count() + orders_to_change.count()
qs_wl = event.waitinglistentries.filter(voucher__isnull=True).select_related('subevent')
if subevents:
qs_wl = qs_wl.filter(subevent__in=subevents)
qs_wl = event.waitinglistentries.filter(subevent=subevent, voucher__isnull=True)
if send_waitinglist:
total += qs_wl.count()
counter = 0
@@ -182,10 +170,6 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * (o.total - fee_sum)
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
if keep_fee_per_ticket:
for p in o.positions.all():
if p.addon_to_id is None:
fee += min(p.price, Decimal(keep_fee_per_ticket))
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
@@ -217,20 +201,16 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
with transaction.atomic():
o = event.orders.select_for_update().get(pk=o)
total = Decimal('0.00')
fee = Decimal('0.00')
positions = []
ocm = OrderChangeManager(o, user=user, notify=False)
for p in o.positions.all():
if p.subevent_id in subevent_ids:
if p.subevent == subevent:
total += p.price
ocm.cancel(p)
positions.append(p)
if keep_fee_per_ticket:
if p.addon_to_id is None:
fee += min(p.price, Decimal(keep_fee_per_ticket))
fee = Decimal('0.00')
if keep_fee_fixed:
fee += Decimal(keep_fee_fixed)
if keep_fee_percentage:
@@ -266,7 +246,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
if send_waitinglist:
for wle in qs_wl:
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, subevent)
counter += 1
if not self.request.called_directly and counter % max(10, total // 100) == 0:

View File

@@ -66,7 +66,6 @@ error_messages = {
"%(min)s items of it."),
'not_started': _('The presale period for this event has not yet started.'),
'ended': _('The presale period for this event has ended.'),
'payment_ended': _('All payments for this event need to be confirmed already, so no new orders can be created.'),
'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions '
'have been removed from your cart.'),
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
@@ -170,7 +169,7 @@ class CartManager:
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['payment_ended'])
raise CartError(error_messages['ended'])
def _extend_expiry_of_valid_existing_positions(self):
# Extend this user's cart session to ensure all items in the cart expire at the same time
@@ -305,7 +304,7 @@ class CartManager:
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['payment_ended'])
raise CartError(error_messages['ended'])
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
@@ -1086,14 +1085,16 @@ def get_fees(event, request, total, invoice_address, provider, positions):
if cs.get('gift_cards'):
gcs = cs['gift_cards']
gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency)
summed = 0
for gc in gc_qs:
if gc.testmode != event.testmode:
gcs.remove(gc.pk)
continue
fval = Decimal(gc.value) # TODO: don't require an extra query
fval = min(fval, total)
fval = min(fval, total - summed)
if fval > 0:
total -= fval
summed += fval
fees.append(OrderFee(
fee_type=OrderFee.FEE_TYPE_GIFTCARD,
internal_type='giftcard',

View File

@@ -7,12 +7,11 @@ from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import now, override
from django.utils.translation import gettext as _
from django_scopes import scope, scopes_disabled
from pretix.base.models import (
Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
)
from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.base.signals import checkin_created, order_placed
from pretix.helpers.jsonlogic import Logic
@@ -230,7 +229,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
list=clist,
datetime=dt,
device=device,
gate=device.gate if device else None,
nonce=nonce,
forced=force and not entry_allowed,
)
@@ -264,23 +262,5 @@ def order_placed(sender, **kwargs):
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, type=Checkin.TYPE_ENTRY)
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True)
checkin_created.send(event, checkin=ci)
@receiver(periodic_task, dispatch_uid="autocheckin_exit_all")
@scopes_disabled()
def process_exit_all(sender, **kwargs):
qs = CheckinList.objects.filter(
exit_all_at__lte=now(),
exit_all_at__isnull=False
).select_related('event', 'event__organizer')
for cl in qs:
for p in cl.positions_inside:
with scope(organizer=cl.event.organizer):
ci = Checkin.objects.create(
position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at
)
checkin_created.send(cl.event, checkin=ci)
cl.exit_all_at = cl.exit_all_at + timedelta(days=1)
cl.save(update_fields=['exit_all_at'])

View File

@@ -7,7 +7,7 @@ import ssl
import warnings
from email.mime.image import MIMEImage
from email.utils import formataddr
from typing import Any, Dict, List, Sequence, Union
from typing import Any, Dict, List, Union
from urllib.parse import urljoin, urlparse
import cssutils
@@ -27,7 +27,7 @@ from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Event, Invoice, InvoiceAddress, Order, OrderPosition, User,
Event, Invoice, InvoiceAddress, Order, OrderPosition, User,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask
@@ -52,11 +52,10 @@ class SendMailException(Exception):
pass
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None,
order: Order = None, position: OrderPosition = None, headers: dict = None, sender: str = None,
invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, attach_ical=False,
attach_cached_files: Sequence = None):
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
invoices: list=None, attach_tickets=False, auto_email=True, user=None, attach_ical=False):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -97,8 +96,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param user: The user this email is sent to
:param attach_cached_files: A list of cached file to attach to this email.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
@@ -217,7 +214,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
body_html = None
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
to=[email],
bcc=bcc,
subject=subject,
body=body_plain,
@@ -230,8 +227,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
position=position.pk if position else None,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
user=user.pk if user else None,
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
user=user.pk if user else None
)
if invoices:
@@ -259,9 +255,9 @@ class CustomEmail(EmailMultiAlternatives):
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
attach_ical=False, attach_cached_files: List[int] = None) -> bool:
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
invoices: List[int]=None, order: int=None, attach_tickets=False, user=None,
attach_ical=False) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
@@ -353,26 +349,13 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Could not attach invoice to email')
pass
if attach_cached_files:
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
if cf.file:
try:
email.attach(
cf.filename,
cf.file.file.read(),
cf.type,
)
except:
logger.exception('Could not attach file to email')
pass
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order)
try:
backend.send_messages([email])
except smtplib.SMTPResponseException as e:
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
logger.exception('Error sending email')
if order:
@@ -389,7 +372,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
if isinstance(e, (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError, ssl.SSLError, OSError)):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
if order:
order.log_action(
'pretix.event.order.email.error',

View File

@@ -91,7 +91,7 @@ def send_notification_mail(notification: Notification, user: User):
ctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'color': settings.PRETIX_PRIMARY_COLOR,
'color': '#8E44B3',
'notification': notification,
'settings_url': build_absolute_uri(
'control:user.settings.notifications',

View File

@@ -103,7 +103,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
order._address.name_parts = {'_scheme': event.settings.name_scheme}
orders.append(order)
position = OrderPosition(positionid=len(order._positions) + 1)
position = OrderPosition()
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
position.meta_info = {}
order._positions.append(position)

View File

@@ -32,13 +32,13 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
InvoiceAddress, OrderFee, OrderRefund, generate_position_secret,
generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxRule
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
@@ -371,10 +371,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
position.canceled = True
assign_ticket_secret(
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
position.save(update_fields=['canceled', 'secret'])
position.save(update_fields=['canceled'])
new_fee = cancellation_fee
for fee in order.fees.all():
if keep_fees and fee in keep_fees:
@@ -409,9 +406,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.save(update_fields=['status', 'cancellation_date'])
for position in order.positions.all():
assign_ticket_secret(
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=True
)
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
@@ -624,10 +618,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
except ItemBundle.MultipleObjectsReturned:
raise OrderError("Invalid product configuration (duplicate bundle)")
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
custom_price_is_tax_rate=cp.override_tax_rate,
invoice_address=address, force_custom_price=True, max_discount=max_discount)
changed_prices[cp.pk] = bprice
else:
@@ -639,10 +631,10 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
max_discount=max_discount)
pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum,
max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate)
max_discount=max_discount)
if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross)
@@ -1059,7 +1051,7 @@ def send_download_reminders(sender, **kwargs):
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only('pk', 'event_id', 'sales_channel').order_by('event_id')
).only('pk', 'event_id').order_by('event_id')
event_id = None
days = None
event = None
@@ -1570,9 +1562,6 @@ class OrderChangeManager:
invoice_address=self._invoice_address
).gross
)
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
@@ -1584,9 +1573,6 @@ class OrderChangeManager:
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
@@ -1598,9 +1584,7 @@ class OrderChangeManager:
'new_price': op.position.price
})
op.position.subevent = op.subevent
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=False, save=False
)
op.position.save()
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
op.position.price_before_voucher = max(
op.position.price,
@@ -1611,7 +1595,6 @@ class OrderChangeManager:
invoice_address=self._invoice_address
).gross
)
op.position.save()
elif isinstance(op, self.AddFeeOperation):
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
'fee': op.fee.pk,
@@ -1690,10 +1673,7 @@ class OrderChangeManager:
opa.canceled = True
if opa.voucher:
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
opa.save(update_fields=['canceled', 'secret'])
opa.save(update_fields=['canceled'])
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
@@ -1705,10 +1685,7 @@ class OrderChangeManager:
op.position.canceled = True
if op.position.voucher:
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
)
op.position.save(update_fields=['canceled', 'secret'])
op.position.save(update_fields=['canceled'])
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
@@ -1730,9 +1707,8 @@ class OrderChangeManager:
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
elif isinstance(op, self.RegenerateSecretOperation):
assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=True, save=True
)
op.position.secret = generate_position_secret()
op.position.save()
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
@@ -1765,9 +1741,7 @@ class OrderChangeManager:
'new_order': split_order.code,
})
op.order = split_order
assign_ticket_secret(
self.event, position=op, force_invalidate=True,
)
op.secret = generate_position_secret()
op.save()
try:
@@ -1908,7 +1882,7 @@ class OrderChangeManager:
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
if self.reissue_invoice and self._invoice_dirty:
if i and not i.refered.exists():
if i:
self._invoices.append(generate_cancellation(i))
if invoice_qualified(self.order) and \
(i or
@@ -2231,11 +2205,8 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards")
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_giftcards")
@transaction.atomic()
def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
if order.status != Order.STATUS_PAID:
return
any_giftcards = False
for p in order.positions.all():
if p.item.issue_giftcard:

View File

@@ -11,7 +11,6 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
custom_price_is_tax_rate: Decimal=None,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'),
max_discount: Decimal = None, tax_rule=None) -> TaxedPrice:
@@ -67,7 +66,7 @@ def get_price(item: Item, variation: ItemVariation = None,
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net',
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', gross_price_is_tax_rate=custom_price_is_tax_rate,
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross',
invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)

View File

@@ -17,7 +17,7 @@ from pretix.celery_app import app
@app.task(base=ProfiledEventTask)
def export(event: Event, shredders: List[str]) -> None:
def export(event: Event, shredders: List[str], session_key=None) -> None:
known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile:
@@ -55,6 +55,8 @@ def export(event: Event, shredders: List[str]) -> None:
cf.date = now()
cf.filename = event.slug + '.zip'
cf.type = 'application/zip'
cf.session_key = session_key
cf.web_download = True
cf.expires = now() + timedelta(hours=1)
cf.save()
cf.file.save(cachedfile_name(cf, cf.filename), rawfile)

View File

@@ -374,10 +374,6 @@ DEFAULTS = {
'default': 'classic',
'type': str,
},
'ticket_secret_generator': {
'default': 'random',
'type': str,
},
'reservation_time': {
'default': '30',
'type': int,
@@ -672,8 +668,8 @@ DEFAULTS = {
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
'form_kwargs': lambda: dict(label=_('Country'), **country_choice_kwargs()),
'serializer_kwargs': country_choice_kwargs,
'form_kwargs': country_choice_kwargs,
},
'invoice_address_from_tax_id': {
'default': '',
@@ -975,19 +971,6 @@ DEFAULTS = {
'data-checkbox-dependency-visual': 'on'}),
)
},
'ticket_download_require_validated_email': {
'default': 'False',
'type': bool,
'serializer_class': serializers.BooleanField,
'form_class': forms.BooleanField,
'form_kwargs': dict(
label=_("Do not issue ticket before email address is validated"),
help_text=_("If turned on, tickets will not be offered for download directly after purchase. They will "
"be attached to the payment confirmation email (if the file size is not too large), and the "
"customer will be able to download them from the page as soon as they clicked a link in "
"the email. Does not affect orders performed through other sales channels."),
)
},
'event_list_availability': {
'default': 'True',
'type': bool
@@ -1597,7 +1580,7 @@ Your {event} team"""))
'type': bool
},
'primary_color': {
'default': settings.PRETIX_PRIMARY_COLOR,
'default': '#8E44B3',
'type': str,
},
'theme_color_success': {
@@ -1842,15 +1825,6 @@ Your {event} team"""))
'seating_distance_within_row': {
'default': 'False',
'type': bool
},
'checkout_show_copy_answers_button': {
'default': 'True',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Show button to copy user input from other products"),
),
}
}
PERSON_NAME_TITLE_GROUPS = OrderedDict([
@@ -1862,7 +1836,7 @@ PERSON_NAME_TITLE_GROUPS = OrderedDict([
'Mx',
'Dr',
'Professor',
'Sir',
'Sir'
))),
('german_common', (_('Most common German titles'), (
'Dr.',
@@ -1870,16 +1844,9 @@ PERSON_NAME_TITLE_GROUPS = OrderedDict([
'Prof. Dr.',
)))
])
PERSON_NAME_SALUTATIONS = [
pgettext_lazy("person_name_salutation", "Ms"),
pgettext_lazy("person_name_salutation", "Mr"),
]
PERSON_NAME_SCHEMES = OrderedDict([
('given_family', {
'fields': (
# field_name, label, weight for widget width
('given_name', _('Given name'), 1),
('family_name', _('Family name'), 1),
),
@@ -2034,24 +2001,6 @@ PERSON_NAME_SCHEMES = OrderedDict([
'_scheme': 'full_transcription',
},
}),
('salutation_title_given_family', {
'fields': (
('salutation', pgettext_lazy('person_name', 'Salutation'), 1),
('title', pgettext_lazy('person_name', 'Title'), 1),
('given_name', _('Given name'), 2),
('family_name', _('Family name'), 2),
),
'concatenation': lambda d: ' '.join(
str(p) for p in (d.get(key, '') for key in ["title", "given_name", "family_name"]) if p
),
'sample': {
'salutation': pgettext_lazy('person_name_sample', 'Mr'),
'title': pgettext_lazy('person_name_sample', 'Dr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'salutation_title_given_family',
},
}),
])
COUNTRIES_WITH_STATE_IN_ADDRESS = {
# Source: http://www.bitboost.com/ref/international-address-formats.html
@@ -2067,6 +2016,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'US': (['State', 'Outlying area', 'District'], 'short'),
}
settings_hierarkey = Hierarkey(attribute_name='settings')
for k, v in DEFAULTS.items():
@@ -2136,7 +2086,7 @@ class SettingsSandbox:
def __delattr__(self, key: str) -> None:
del self._event.settings[self._convert_key(key)]
def get(self, key: str, default: Any = None, as_type: type = str):
def get(self, key: str, default: Any=None, as_type: type=str):
return self._event.settings.get(self._convert_key(key), default=default, as_type=as_type)
def set(self, key: str, value: Any):

View File

@@ -216,16 +216,6 @@ subclass of pretix.base.invoice.BaseInvoiceRenderer or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_ticket_secret_generators = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out to get all known ticket secret generators. Receivers should return a
subclass of ``pretix.base.secrets.BaseTicketSecretGenerator`` or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_data_shredders = EventPluginSignal(
providing_args=[]
)

View File

@@ -4,4 +4,3 @@
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}{% if widget.value.is_img %}<br><a href="{{ widget.value.url }}" data-lightbox="{{ widget.value.name }}"><img src="{{ widget.value|thumb:"200x100" }}" /></a>{% endif %}<br>
{{ widget.input_text }}:{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% if widget.cachedfile %}<input type="hidden" name="{{ widget.hidden_name }}" value="{{ widget.cachedfile.id }}">{% endif %}

View File

@@ -1,3 +1,4 @@
import re
import urllib.parse
import bleach
@@ -71,6 +72,10 @@ EMAIL_RE = build_email_re(tlds=sorted(tld_set, key=len, reverse=True))
def safelink_callback(attrs, new=False):
"""
Makes sure that all links to a different domain are passed through a redirection handler
to ensure there's no passing of referers with secrets inside them.
"""
url = attrs.get((None, 'href'), '/')
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect')
@@ -80,7 +85,42 @@ def safelink_callback(attrs, new=False):
return attrs
def truelink_callback(attrs, new=False):
"""
Tries to prevent "phishing" attacks in which a link looks like it points to a safe place but instead
points somewhere else, e.g.
<a href="https://evilsite.com">https://google.com</a>
At the same time, custom texts are still allowed:
<a href="https://maps.google.com">Get to the event</a>
Suffixes are also allowed:
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
"""
text = re.sub('[^a-zA-Z0-9.-/_]', '', attrs.get('_text')) # clean up link text
if URL_RE.match(text):
# link text looks like a url
if text.startswith('//'):
text = 'https:' + text
elif not text.startswith('http'):
text = 'https://' + text
text_url = urllib.parse.urlparse(text)
href_url = urllib.parse.urlparse(attrs[None, 'href'])
if text_url.netloc != href_url.netloc or not href_url.path.startswith(href_url.path):
# link text contains an URL that has a different base than the actual URL
attrs['_text'] = attrs[None, 'href']
return attrs
def abslink_callback(attrs, new=False):
"""
Makes sure that all links will be absolute links and will be opened in a new page with no
window.opener attribute.
"""
url = attrs.get((None, 'href'), '/')
if not url.startswith('mailto:') and not url.startswith('tel:'):
attrs[None, 'href'] = urllib.parse.urljoin(settings.SITE_URL, url)
@@ -93,6 +133,7 @@ def markdown_compile_email(source):
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True
)
return linker.linkify(bleach.clean(
@@ -145,7 +186,7 @@ def rich_text(text: str, **kwargs):
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback]),
callbacks=DEFAULT_CALLBACKS + ([truelink_callback, safelink_callback] if kwargs.get('safelinks', True) else [truelink_callback, abslink_callback]),
parse_email=True
)
body_md = linker.linkify(markdown_compile(text))
@@ -161,7 +202,7 @@ def rich_text_snippet(text: str, **kwargs):
linker = bleach.Linker(
url_re=URL_RE,
email_re=EMAIL_RE,
callbacks=DEFAULT_CALLBACKS + ([safelink_callback] if kwargs.get('safelinks', True) else [abslink_callback]),
callbacks=DEFAULT_CALLBACKS + ([truelink_callback, safelink_callback] if kwargs.get('safelinks', True) else [truelink_callback, abslink_callback]),
parse_email=True
)
body_md = linker.linkify(markdown_compile(text, snippet=True))

View File

@@ -13,7 +13,11 @@ class DownloadView(TemplateView):
@cached_property
def object(self) -> CachedFile:
try:
return get_object_or_404(CachedFile, id=self.kwargs['id'])
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
if o.session_key:
if o.session_key != self.request.session.session_key:
raise Http404()
return o
except ValueError: # Invalid URLs
raise Http404()

View File

@@ -14,7 +14,6 @@ from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionOption,
)
from pretix.presale.signals import contact_form_fields_overrides
class BaseQuestionsViewMixin:
@@ -35,9 +34,6 @@ class BaseQuestionsViewMixin:
def _positions_for_questions(self):
raise NotImplementedError()
def get_question_override_sets(self, position):
return []
@cached_property
def forms(self):
"""
@@ -57,32 +53,7 @@ class BaseQuestionsViewMixin:
data=(self.request.POST if self.request.method == 'POST' else None),
files=(self.request.FILES if self.request.method == 'POST' else None))
form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
(form.pos.addon_to.item.admission and form.pos.item.admission and (
self.request.event.settings.attendee_names_asked or
self.request.event.settings.attendee_emails_asked or
self.request.event.settings.attendee_company_asked or
self.request.event.settings.attendee_addresses_asked
))
)
override_sets = self.get_question_override_sets(cr)
for overrides in override_sets:
for question_name, question_field in form.fields.items():
if hasattr(question_field, 'question'):
if question_field.question.identifier in overrides:
if 'initial' in overrides[question_field.question.identifier]:
question_field.initial = overrides[question_field.question.identifier]['initial']
if 'disabled' in overrides[question_field.question.identifier]:
question_field.disabled = overrides[question_field.question.identifier]['disabled']
else:
if question_name in overrides:
if 'initial' in overrides[question_name]:
question_field.initial = overrides[question_name]['initial']
if 'disabled' in overrides[question_name]:
question_field.disabled = overrides[question_name]['disabled']
form.show_copy_answers_to_addon_button = form.pos.addon_to and set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all())
if len(form.fields) > 0:
formlist.append(form)
return formlist
@@ -234,47 +205,24 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
self.order.total != Decimal('0.00') or not self.request.event.settings.invoice_address_not_asked_free
)
@cached_property
def _contact_override_sets(self):
override_sets = [
resp for recv, resp in contact_form_fields_overrides.send(
self.request.event,
request=self.request,
order=self.order,
)
]
for override in override_sets:
for k in override:
# We don't want initial values to be modified, they should come from the order directly
override[k].pop('initial', None)
return override_sets
@cached_property
def invoice_form(self):
if not self.address_asked and self.request.event.settings.invoice_name_required:
f = self.invoice_name_form_class(
return self.invoice_name_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional
)
elif self.address_asked:
f = self.invoice_form_class(
if self.address_asked:
return self.invoice_form_class(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
instance=self.invoice_address, validate_vat_id=False,
all_optional=self.all_optional,
)
else:
f = forms.Form(data=self.request.POST if self.request.method == "POST" else None)
override_sets = self._contact_override_sets
for overrides in override_sets:
for fname, val in overrides.items():
if 'disabled' in val and fname in f.fields:
f.fields[fname].disabled = val['disabled']
return f
return forms.Form(data=self.request.POST if self.request.method == "POST" else None)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)

View File

@@ -7,7 +7,6 @@ from django.core.exceptions import ValidationError
from django.core.files import File
from django.core.files.uploadedfile import UploadedFile
from django.forms.utils import from_current_timezone
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from ...base.forms import I18nModelForm
@@ -78,8 +77,6 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
@property
def name(self):
if hasattr(self.file, 'display_name'):
return self.file.display_name
return self.file.name
@property
@@ -87,8 +84,6 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
return any(self.file.name.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
def __str__(self):
if hasattr(self.file, 'display_name'):
return self.file.display_name
return os.path.basename(self.file.name).split('.', 1)[-1]
@property
@@ -98,48 +93,6 @@ class ClearableBasenameFileInput(forms.ClearableFileInput):
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['widget']['value'] = self.FakeFile(value)
ctx['widget']['cachedfile'] = None
return ctx
class CachedFileInput(forms.ClearableFileInput):
template_name = 'pretixbase/forms/widgets/thumbnailed_file_input.html'
class FakeFile(File):
def __init__(self, file):
self.file = file
@property
def name(self):
return self.file.filename
@property
def is_img(self):
return any(self.file.filename.lower().endswith(e) for e in ('.jpg', '.jpeg', '.png', '.gif'))
def __str__(self):
return self.file.filename
@property
def url(self):
return self.file.file.url
def value_from_datadict(self, data, files, name):
from ...base.models import CachedFile
v = super().value_from_datadict(data, files, name)
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
return v
def get_context(self, name, value, attrs):
from ...base.models import CachedFile
if isinstance(value, CachedFile):
value = self.FakeFile(value)
ctx = super().get_context(name, value, attrs)
ctx['widget']['value'] = value
ctx['widget']['cachedfile'] = value.file if isinstance(value, self.FakeFile) else None
ctx['widget']['hidden_name'] = name + '-cachedfile'
return ctx
@@ -176,7 +129,7 @@ class ExtFileField(SizeFileField):
def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs)
if isinstance(data, File):
if data:
filename = data.name
ext = os.path.splitext(filename)[1]
ext = ext.lower()
@@ -185,49 +138,6 @@ class ExtFileField(SizeFileField):
return data
class CachedFileField(ExtFileField):
widget = CachedFileInput
def to_python(self, data):
from ...base.models import CachedFile
if isinstance(data, CachedFile):
return data
return super().to_python(data)
def bound_data(self, data, initial):
from ...base.models import CachedFile
if isinstance(data, File):
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
filename=data.name,
type=data.content_type,
)
cf.file.save(data.name, data.file)
cf.save()
return cf
return super().bound_data(data, initial)
def clean(self, *args, **kwargs):
from ...base.models import CachedFile
data = super().clean(*args, **kwargs)
if isinstance(data, File):
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
filename=data.name,
type=data.content_type,
)
cf.file.save(data.name, data.file)
cf.save()
return cf
return data
class SlugWidget(forms.TextInput):
template_name = 'pretixcontrol/slug_widget.html'
prefix = ''

View File

@@ -1,8 +1,5 @@
from datetime import datetime, timedelta
from django import forms
from django.urls import reverse
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -13,21 +10,6 @@ from pretix.base.models.checkin import CheckinList
from pretix.control.forms.widgets import Select2
class NextTimeField(forms.TimeField):
def to_python(self, value):
value = super().to_python(value)
if value is None:
return
tz = get_current_timezone()
result = make_aware(datetime.combine(
now().astimezone(tz).date(),
value,
), tz)
if result <= now():
result += timedelta(days=1)
return result
class CheckinListForm(forms.ModelForm):
def __init__(self, **kwargs):
self.event = kwargs.pop('event')
@@ -44,11 +26,6 @@ class CheckinListForm(forms.ModelForm):
widget=forms.CheckboxSelectMultiple
)
if not self.event.organizer.gates.exists():
del self.fields['gates']
else:
self.fields['gates'].queryset = self.event.organizer.gates.all()
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
@@ -78,24 +55,16 @@ class CheckinListForm(forms.ModelForm):
'allow_multiple_entries',
'allow_entry_after_exit',
'rules',
'gates',
'exit_all_at',
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '<[name$=all_products]'
}),
'gates': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(),
'exit_all_at': forms.TimeInput(attrs={'class': 'timepickerfield'}),
}
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'gates': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
'exit_all_at': NextTimeField,
}
@@ -106,11 +75,6 @@ class SimpleCheckinListForm(forms.ModelForm):
super().__init__(**kwargs)
self.fields['limit_products'].queryset = self.event.items.all()
if not self.event.organizer.gates.exists():
del self.fields['gates']
else:
self.fields['gates'].queryset = self.event.organizer.gates.all()
class Meta:
model = CheckinList
localized_fields = '__all__'
@@ -119,19 +83,13 @@ class SimpleCheckinListForm(forms.ModelForm):
'all_products',
'limit_products',
'include_pending',
'allow_entry_after_exit',
'gates',
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '<[name$=all_products]'
}),
'gates': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
}
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
'gates': SafeModelMultipleChoiceField,
}

View File

@@ -522,7 +522,6 @@ class EventSettingsForm(SettingsForm):
'banner_text_bottom',
'order_email_asked_twice',
'last_order_modification_date',
'checkout_show_copy_answers_button',
]
def clean(self):
@@ -1067,22 +1066,7 @@ class TicketSettingsForm(SettingsForm):
'ticket_download_addons',
'ticket_download_nonadm',
'ticket_download_pending',
'ticket_download_require_validated_email',
]
ticket_secret_generator = forms.ChoiceField(
label=_("Ticket code generator"),
help_text=_("For advanced users, usually does not need to be changed."),
required=True,
widget=forms.RadioSelect,
choices=[]
)
def __init__(self, *args, **kwargs):
event = kwargs.get('obj')
super().__init__(*args, **kwargs)
self.fields['ticket_secret_generator'].choices = [
(r.identifier, r.verbose_name) for r in event.ticket_secret_generators.values()
]
def prepare_fields(self):
# See clean()
@@ -1164,7 +1148,7 @@ TaxRuleLineFormSet = formset_factory(
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
fields = ['name', 'rate', 'price_includes_tax']
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
class WidgetCodeForm(forms.Form):

View File

@@ -287,11 +287,11 @@ class EventOrderFilterForm(OrderFilterForm):
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i))))
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else:
choices.append((str(i.pk), str(i)))
choices.append((str(i.pk), i.name))
self.fields['item'].choices = choices
def filter_qs(self, qs):
@@ -827,8 +827,6 @@ class CheckInFilterForm(FilterForm):
'-item': ('-item__name', '-variation__value', '-order__code'),
'seat': ('seat__sorting_rank', 'seat__guid'),
'-seat': ('-seat__sorting_rank', '-seat__guid'),
'date': ('subevent__date_from', 'order__code'),
'-date': ('-subevent__date_from', '-order__code'),
'name': {'_order': F('display_name').asc(nulls_first=True),
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
'-name': {'_order': F('display_name').desc(nulls_last=True),

View File

@@ -400,6 +400,7 @@ class OrderPositionChangeForm(forms.Form):
self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance
if not instance.seat and not (
not instance.event.settings.seating_choice and
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
):
del self.fields['seat']
@@ -516,20 +517,6 @@ class OrderMailForm(forms.Form):
self._set_field_placeholders('message', ['event', 'order'])
class OrderPositionMailForm(OrderMailForm):
def __init__(self, *args, **kwargs):
position = self.position = kwargs.pop('position')
super().__init__(*args, **kwargs)
self.fields['sendto'].initial = position.attendee_email
self.fields['message'] = forms.CharField(
label=_("Message"),
required=True,
widget=forms.Textarea,
initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale),
)
self._set_field_placeholders('message', ['event', 'order', 'position'])
class OrderRefundForm(forms.Form):
action = forms.ChoiceField(
required=False,
@@ -585,21 +572,7 @@ class EventCancelForm(forms.Form):
all_subevents = forms.BooleanField(
label=_('Cancel all dates'),
initial=False,
required=False,
)
subevents_from = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
'data-inverse-dependency': '#id_all_subevents',
}),
label=pgettext_lazy('subevent', 'All dates starting at or after'),
required=False,
)
subevents_to = forms.SplitDateTimeField(
widget=SplitDateTimePickerWidget(attrs={
'data-inverse-dependency': '#id_all_subevents',
}),
label=pgettext_lazy('subevent', 'All dates starting before'),
required=False,
required=False
)
auto_refund = forms.BooleanField(
label=_('Automatically refund money if possible'),
@@ -640,12 +613,6 @@ class EventCancelForm(forms.Form):
max_digits=10, decimal_places=2,
required=False
)
keep_fee_per_ticket = forms.DecimalField(
label=_("Keep a fixed cancellation fee per ticket"),
help_text=_("Free tickets and add-on products are not counted"),
max_digits=10, decimal_places=2,
required=False
)
keep_fee_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
max_digits=10, decimal_places=2,
@@ -750,7 +717,6 @@ class EventCancelForm(forms.Form):
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-inverse-dependency': '#id_all_subevents',
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
@@ -767,12 +733,6 @@ class EventCancelForm(forms.Form):
def clean(self):
d = super().clean()
if d.get('subevent') and d.get('subevents_from'):
raise ValidationError(pgettext_lazy('subevent', 'Please either select a specific date or a date range, not both.'))
if d.get('all_subevents') and d.get('subevent_from'):
raise ValidationError(pgettext_lazy('subevent', 'Please either select all subevents or a date range, not both.'))
if bool(d.get('subevents_from')) != bool(d.get('subevents_to')):
raise ValidationError(pgettext_lazy('subevent', 'If you set a date range, please set both a start and an end.'))
if self.event.has_subevents and not d['subevent'] and not d['all_subevents'] and not d.get('subevents_from'):
if self.event.has_subevents and not d['subevent'] and not d['all_subevents']:
raise ValidationError(_('Please confirm that you want to cancel ALL dates in this event series.'))
return d

View File

@@ -15,7 +15,7 @@ from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Device, Gate, GiftCard, Organizer, Team
from pretix.base.models import Device, GiftCard, Organizer, Team
from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, SplitDateTimeField,
)
@@ -175,17 +175,6 @@ class TeamForm(forms.ModelForm):
return data
class GateForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
super().__init__(*args, **kwargs)
class Meta:
model = Gate
fields = ['name', 'identifier']
class DeviceForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
@@ -194,7 +183,6 @@ class DeviceForm(forms.ModelForm):
self.fields['limit_events'].queryset = organizer.events.all().order_by(
'-has_subevents', '-date_from'
)
self.fields['gate'].queryset = organizer.gates.all()
def clean(self):
d = super().clean()
@@ -205,7 +193,7 @@ class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ['name', 'all_events', 'limit_events', 'security_profile', 'gate']
fields = ['name', 'all_events', 'limit_events']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events',

Some files were not shown because too many files have changed in this diff Show More