mirror of
https://github.com/pretix/pretix.git
synced 2025-12-15 14:02:27 +00:00
Compare commits
68 Commits
transfer-t
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91c02dc0b3 | ||
|
|
f78ec830b5 | ||
|
|
9f0e508ab3 | ||
|
|
4ca50d750b | ||
|
|
07c1b1b7f3 | ||
|
|
3e95dd52cf | ||
|
|
80ef2f6b0e | ||
|
|
53a8cda310 | ||
|
|
63de49104c | ||
|
|
8aa80bcb84 | ||
|
|
95115a7c5e | ||
|
|
ce2967fd02 | ||
|
|
399fb87d20 | ||
|
|
c4bd5ac5df | ||
|
|
123c2d6c02 | ||
|
|
6954e9c984 | ||
|
|
fc573e4e48 | ||
|
|
0dbcfdc5ac | ||
|
|
4b8d4b4792 | ||
|
|
d798da33ef | ||
|
|
d99517c8d1 | ||
|
|
0787adcb8e | ||
|
|
f848561d25 | ||
|
|
efbddc2486 | ||
|
|
e6a138d8f2 | ||
|
|
5b7a578307 | ||
|
|
737738de93 | ||
|
|
eb3951ce13 | ||
|
|
c2b7d9a257 | ||
|
|
4738aa2771 | ||
|
|
29ac0af55e | ||
|
|
96bc64c456 | ||
|
|
0369deb72d | ||
|
|
6e53990845 | ||
|
|
feb262644e | ||
|
|
abd679820f | ||
|
|
cd3ce848d1 | ||
|
|
63ba393c12 | ||
|
|
23fdf8c457 | ||
|
|
304ad4e3db | ||
|
|
ec58ab07b6 | ||
|
|
1ba4047b1b | ||
|
|
0bab8adc41 | ||
|
|
17e09c601e | ||
|
|
1aca5fb6ff | ||
|
|
7860d690fa | ||
|
|
6d01c99d38 | ||
|
|
ddb645aeea | ||
|
|
f08e4b41c4 | ||
|
|
1e23624955 | ||
|
|
ee951a7448 | ||
|
|
9935ba370d | ||
|
|
e815cce143 | ||
|
|
cea1032180 | ||
|
|
5695e1d9c8 | ||
|
|
fd317afd01 | ||
|
|
ccddd2a96f | ||
|
|
513d3034d8 | ||
|
|
51495187fa | ||
|
|
2bd53f7b9f | ||
|
|
06d9c48ed4 | ||
|
|
1155d18b7f | ||
|
|
6e14592c78 | ||
|
|
55feaf2d2c | ||
|
|
c487036c8b | ||
|
|
853ebf8c70 | ||
|
|
1c695c1cf9 | ||
|
|
bd5687d169 |
9
doc/api/auth.rst
Normal file
9
doc/api/auth.rst
Normal file
@@ -0,0 +1,9 @@
|
||||
Authentication
|
||||
==============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
tokenauth
|
||||
oauth
|
||||
deviceauth
|
||||
137
doc/api/deviceauth.rst
Normal file
137
doc/api/deviceauth.rst
Normal file
@@ -0,0 +1,137 @@
|
||||
.. _`rest-deviceauth`:
|
||||
|
||||
Device authentication
|
||||
=====================
|
||||
|
||||
Initializing a new device
|
||||
-------------------------
|
||||
|
||||
Users can create new devices in the "Device" section of their organizer settings. When creating
|
||||
a new device, users can specify a list of events the device is allowed to access. After a new
|
||||
device is created, users will be presented initialization instructions, consisting of an URL
|
||||
and an initialization token. They will also be shown as a QR code with the following contents::
|
||||
|
||||
{"handshake_version": 1, "url": "https://pretix.eu", "token": "kpp4jn8g2ynzonp6"}
|
||||
|
||||
Your application should be able to scan a QR code of this type, or allow to enter the URL and the
|
||||
initialization token manually. The handshake version is not used for manual initialization. When a
|
||||
QR code is scanned with a higher handshake version than you support, you should reject the request
|
||||
and prompt the user to update the client application.
|
||||
|
||||
After your application received the token, you need to call the initialization endpoint to obtain
|
||||
a proper API token. At this point, you need to identify the name and version of your application,
|
||||
as well as the type of underlying hardware. Example:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/initialize HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"token": "kpp4jn8g2ynzonp6",
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.0.0"
|
||||
}
|
||||
|
||||
Every initialization token can only be used once. On success, you will receive a response containing
|
||||
information on your device as well as your API token:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"organizer": "foo",
|
||||
"device_id": 5,
|
||||
"unique_serial": "HHZ9LW9JWP390VFZ",
|
||||
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
|
||||
"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.
|
||||
|
||||
In case of an error, the response will look like this:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: application/json
|
||||
|
||||
{"token":["This initialization token has already been used."]}
|
||||
|
||||
|
||||
Performing API requests
|
||||
-----------------------
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
Updating the software version
|
||||
-----------------------------
|
||||
|
||||
If your application is updated, we ask you to tell the server about the new version in use. You can do this at the
|
||||
following endpoint:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/update HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Content-Type: application/json
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
{
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.1.0"
|
||||
}
|
||||
|
||||
Creating a new API key
|
||||
----------------------
|
||||
|
||||
If you think your API key might have leaked or just want to be extra cautious, the API allows you to create a new key.
|
||||
The old API key will be invalid immediately. A request for a new key looks like this:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/device/roll HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
||||
|
||||
The response will look like the response to the initialization request.
|
||||
|
||||
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.
|
||||
|
||||
Permissions
|
||||
-----------
|
||||
|
||||
Device authentication is currently hardcoded to grant the following permissions:
|
||||
|
||||
* View event meta data and products etc.
|
||||
* View and change orders
|
||||
|
||||
Devices cannot change events or products and cannot access vouchers.
|
||||
@@ -9,44 +9,20 @@ with pretix' REST API, such as authentication, pagination and similar definition
|
||||
Authentication
|
||||
--------------
|
||||
|
||||
If you're building an application for end users, we strongly recommend that you use our
|
||||
:ref:`OAuth-based authentication progress <rest-oauth>`. However, for simpler needs, you
|
||||
can also go with static API tokens that you can create on a per-team basis (see below).
|
||||
To access the API, you need to present valid authentication credentials. pretix currently
|
||||
supports the following authorization schemes:
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
|
||||
.. note:: The API currently also supports authentication via browser sessions, i.e. the
|
||||
same way that you authenticate with pretix when using the browser interface.
|
||||
Using this type of authentication is *not* officially supported for use by
|
||||
third-party clients and might change or be removed at any time. We plan on
|
||||
adding OAuth2 support in the future for user-level authentication. If you want
|
||||
to use session authentication, be sure to comply with Django's `CSRF policies`_.
|
||||
|
||||
Obtaining an API token
|
||||
----------------------
|
||||
|
||||
To authenticate your API requests, you need to obtain an API token. You can create a
|
||||
token in the pretix web interface on the level of organizer teams. Create a new team
|
||||
or choose an existing team that has the level of permissions the token should have and
|
||||
create a new token using the form below the list of team members:
|
||||
|
||||
.. image:: img/token_form.png
|
||||
:class: screenshot
|
||||
|
||||
You can enter a description for the token to distinguish from other tokens later on.
|
||||
Once you click "Add", you will be provided with an API token in the success message.
|
||||
Copy this token, as you won't be able to retrieve it again.
|
||||
|
||||
.. image:: img/token_success.png
|
||||
:class: screenshot
|
||||
* :ref:`rest-tokenauth`: This is the simplest way and recommended for server-side applications
|
||||
that interact with pretix without user interaction.
|
||||
* :ref:`rest-oauth`: This is the recommended way to use if you write a third-party application
|
||||
that users can connect with their pretix account. It provides the best user experience, but
|
||||
requires user interaction and slightly more implementation effort.
|
||||
* :ref:`rest-deviceauth`: This is the recommended way if you build apps or hardware devices that can
|
||||
connect to pretix, e.g. for processing check-ins or to sell tickets offline. It provides a way
|
||||
to uniquely identify devices and allows for a quick configuration flow inside your software.
|
||||
* Authentication using browser sessions: This is used by the pretix web interface and it is *not*
|
||||
officially supported for use by third-party applications. It might change or be removed at any
|
||||
time without prior notice. If you use it, you need to comply with Django's `CSRF policies`_.
|
||||
|
||||
Permissions
|
||||
-----------
|
||||
@@ -204,4 +180,4 @@ as the string values ``true`` and ``false``.
|
||||
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
|
||||
fields. Prepend a ``-`` to the field name to reverse the sort order.
|
||||
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||
|
||||
@@ -14,5 +14,5 @@ in functionality over time.
|
||||
:maxdepth: 2
|
||||
|
||||
fundamentals
|
||||
oauth
|
||||
auth
|
||||
resources/index
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.. _`rest-oauth`:
|
||||
|
||||
OAuth support / "Connect with pretix"
|
||||
=====================================
|
||||
OAuth authentication / "Connect with pretix"
|
||||
============================================
|
||||
|
||||
In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with
|
||||
pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool
|
||||
@@ -168,4 +168,4 @@ pretix user interface.
|
||||
|
||||
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
|
||||
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
|
||||
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
|
||||
@@ -41,6 +41,10 @@ plugins list A list of packa
|
||||
The ``plugins`` field has been added.
|
||||
The operations POST, PATCH, PUT and DELETE have been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
Filters have been added to the list of events.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -96,6 +100,12 @@ Endpoints
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query is_public: If set to ``true``/``false``, only events with a matching value of ``is_public`` are returned.
|
||||
:query live: If set to ``true``/``false``, only events with a matching value of ``live`` are returned.
|
||||
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
|
||||
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
|
||||
:param organizer: The ``slug`` field of a valid organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
@@ -17,6 +17,7 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the sub-event
|
||||
name multi-lingual string The sub-event's full name
|
||||
event string The slug of the parent event
|
||||
active boolean If ``true``, the sub-event ticket shop is publicly
|
||||
available.
|
||||
date_from datetime The sub-event's start date
|
||||
@@ -40,6 +41,10 @@ meta_data dict Values set for
|
||||
|
||||
The ``meta_data`` field has been added.
|
||||
|
||||
.. versionchanged:: 2.1
|
||||
|
||||
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -72,6 +77,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -92,6 +98,10 @@ Endpoints
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
: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 event to fetch
|
||||
:statuscode 200: no error
|
||||
@@ -121,6 +131,7 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
@@ -144,3 +155,63 @@ Endpoints
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/subevents/
|
||||
|
||||
Returns a list of all sub-events of any event series you have access to within an organizer account.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/subevents/ 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": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "First Sample Conference"},
|
||||
"event": "sampleconf",
|
||||
"active": false,
|
||||
"date_from": "2017-12-27T10:00:00Z",
|
||||
"date_to": null,
|
||||
"date_admission": null,
|
||||
"presale_start": null,
|
||||
"presale_end": null,
|
||||
"location": null,
|
||||
"item_price_overrides": [
|
||||
{
|
||||
"item": 2,
|
||||
"price": "12.00"
|
||||
}
|
||||
],
|
||||
"variation_price_overrides": [],
|
||||
"meta_data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query page: The page number in case of a multi-page result set, default is 1
|
||||
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
|
||||
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
|
||||
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
|
||||
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
|
||||
: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 event 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 it.
|
||||
|
||||
36
doc/api/tokenauth.rst
Normal file
36
doc/api/tokenauth.rst
Normal file
@@ -0,0 +1,36 @@
|
||||
.. _`rest-tokenauth`:
|
||||
|
||||
Token-based authentication
|
||||
==========================
|
||||
|
||||
Obtaining an API token
|
||||
----------------------
|
||||
|
||||
To authenticate your API requests with Tokens, you need to obtain a team-level API token.
|
||||
You can create a token in the pretix web interface on the level of organizer teams. Create
|
||||
a new team or choose an existing team that has the level of permissions the token should
|
||||
have and create a new token using the form below the list of team members:
|
||||
|
||||
.. image:: img/token_form.png
|
||||
:class: screenshot
|
||||
|
||||
You can enter a description for the token to distinguish from other tokens later on.
|
||||
Once you click "Add", you will be provided with an API token in the success message.
|
||||
Copy this token, as you won't be able to retrieve it again.
|
||||
|
||||
.. image:: img/token_success.png
|
||||
:class: screenshot
|
||||
|
||||
Using an API token
|
||||
------------------
|
||||
|
||||
You need to include the API token with every request to pretix' API in the ``Authorization`` header
|
||||
like the following:
|
||||
|
||||
.. sourcecode:: http
|
||||
:emphasize-lines: 3
|
||||
|
||||
GET /api/v1/organizers/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
|
||||
|
||||
@@ -96,8 +96,6 @@ The provider class
|
||||
|
||||
.. automethod:: order_change_allowed
|
||||
|
||||
.. automethod:: order_can_retry
|
||||
|
||||
.. automethod:: payment_prepare
|
||||
|
||||
.. automethod:: payment_control_render
|
||||
|
||||
@@ -23,6 +23,7 @@ cronjob
|
||||
cryptographic
|
||||
debian
|
||||
deduplication
|
||||
deprovision
|
||||
discoverable
|
||||
django
|
||||
dockerfile
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.1.0.dev0"
|
||||
__version__ = "2.1.0"
|
||||
|
||||
25
src/pretix/api/auth/device.py
Normal file
25
src/pretix/api/auth/device.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
from pretix.base.models import Device
|
||||
|
||||
|
||||
class DeviceTokenAuthentication(TokenAuthentication):
|
||||
model = Device
|
||||
keyword = 'Device'
|
||||
|
||||
def authenticate_credentials(self, key):
|
||||
model = self.get_model()
|
||||
try:
|
||||
device = model.objects.select_related('organizer').get(api_token=key)
|
||||
except model.DoesNotExist:
|
||||
raise exceptions.AuthenticationFailed('Invalid token.')
|
||||
|
||||
if not device.initialized:
|
||||
raise exceptions.AuthenticationFailed('Device has not been initialized.')
|
||||
|
||||
if not device.api_token:
|
||||
raise exceptions.AuthenticationFailed('Device access has been revoked.')
|
||||
|
||||
return AnonymousUser(), device
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.models import Device, Event
|
||||
from pretix.base.models.organizer import Organizer, TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
@@ -9,10 +9,9 @@ from pretix.helpers.security import (
|
||||
|
||||
|
||||
class EventPermission(BasePermission):
|
||||
model = TeamAPIToken
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken):
|
||||
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
|
||||
return False
|
||||
|
||||
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
|
||||
@@ -31,7 +30,7 @@ class EventPermission(BasePermission):
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs:
|
||||
request.event = Event.objects.filter(
|
||||
@@ -76,7 +75,7 @@ class EventCRUDPermission(EventPermission):
|
||||
return False
|
||||
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
elif view.action in ['retrieve', 'update', 'partial_update'] \
|
||||
elif view.action in ['update', 'partial_update'] \
|
||||
and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext as _
|
||||
from django_countries.serializers import CountryFieldMixin
|
||||
from rest_framework.fields import Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Event, TaxRule
|
||||
@@ -190,12 +191,13 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class SubEventSerializer(I18nAwareModelSerializer):
|
||||
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
|
||||
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
|
||||
event = SlugRelatedField(slug_field='slug', read_only=True)
|
||||
meta_data = MetaDataField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
|
||||
'presale_start', 'presale_end', 'location',
|
||||
'presale_start', 'presale_end', 'location', 'event',
|
||||
'item_price_overrides', 'variation_price_overrides', 'meta_data')
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
||||
class OrderDownloadsField(serializers.Field):
|
||||
def to_representation(self, instance: Order):
|
||||
if instance.status != Order.STATUS_PAID:
|
||||
return []
|
||||
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
res = []
|
||||
@@ -100,7 +101,8 @@ class OrderDownloadsField(serializers.Field):
|
||||
class PositionDownloadsField(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
if instance.order.status != Order.STATUS_PAID:
|
||||
return []
|
||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||
return []
|
||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
||||
return []
|
||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
||||
@@ -129,12 +131,19 @@ class PdfDataSerializer(serializers.Field):
|
||||
res = {}
|
||||
|
||||
ev = instance.subevent or instance.order.event
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
# we serialize a list.
|
||||
|
||||
pdfvars = get_variables(instance.order.event)
|
||||
for k, f in pdfvars.items():
|
||||
if 'vars' not in self.context:
|
||||
self.context['vars'] = get_variables(self.context['request'].event)
|
||||
|
||||
for k, f in self.context['vars'].items():
|
||||
res[k] = f['evaluate'](instance, instance.order, ev)
|
||||
|
||||
for k, v in ev.meta_data.items():
|
||||
if not hasattr(ev, '_cached_meta_data'):
|
||||
ev._cached_meta_data = ev.meta_data
|
||||
|
||||
for k, v in ev._cached_meta_data.items():
|
||||
res['meta:' + k] = v
|
||||
|
||||
return res
|
||||
@@ -507,6 +516,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if any(errs):
|
||||
raise ValidationError({'positions': errs})
|
||||
|
||||
if validated_data.get('locale', None) is None:
|
||||
validated_data['locale'] = self.context['event'].settings.locale
|
||||
order = Order(event=self.context['event'], **validated_data)
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
|
||||
|
||||
@@ -7,7 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
|
||||
checkin, device, event, item, oauth, order, organizer, voucher,
|
||||
waitinglist,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -15,6 +16,7 @@ router.register(r'organizers', organizer.OrganizerViewSet)
|
||||
|
||||
orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
@@ -65,4 +67,8 @@ urlpatterns = [
|
||||
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
|
||||
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
|
||||
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
|
||||
url(r"^device/initialize$", device.InitializeView.as_view(), name="device.initialize"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@@ -37,6 +37,9 @@ class ConditionalListView:
|
||||
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
|
||||
if if_unmodified_since:
|
||||
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
|
||||
if not hasattr(request, 'event'):
|
||||
return super().list(request, **kwargs)
|
||||
|
||||
lmd = request.event.logentry_set.filter(
|
||||
content_type__model=self.queryset.model._meta.model_name,
|
||||
content_type__app_label=self.queryset.model._meta.app_label,
|
||||
|
||||
113
src/pretix/api/views/device.py
Normal file
113
src/pretix/api/views/device.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
|
||||
from django.utils.timezone import now
|
||||
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 Device
|
||||
from pretix.base.models.devices import generate_api_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InitializationRequestSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(max_length=190)
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
|
||||
|
||||
class UpdateRequestSerializer(serializers.Serializer):
|
||||
hardware_brand = serializers.CharField(max_length=190)
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
|
||||
|
||||
class DeviceSerializer(serializers.ModelSerializer):
|
||||
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'organizer', 'device_id', 'unique_serial', 'api_token',
|
||||
'name'
|
||||
]
|
||||
|
||||
|
||||
class InitializeView(APIView):
|
||||
authentication_classes = tuple()
|
||||
permission_classes = tuple()
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = InitializationRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
device = Device.objects.get(initialization_token=serializer.validated_data.get('token'))
|
||||
except Device.DoesNotExist:
|
||||
raise ValidationError({'token': ['Unknown initialization token.']})
|
||||
|
||||
if device.initialized:
|
||||
raise ValidationError({'token': ['This initialization token has already been used.']})
|
||||
|
||||
device.initialized = now()
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
|
||||
device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class UpdateView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
serializer = UpdateRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
device = request.auth
|
||||
device.hardware_brand = serializer.validated_data.get('hardware_brand')
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.save()
|
||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RollKeyView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
device = request.auth
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
device.log_action('pretix.device.keyroll', auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class RevokeKeyView(APIView):
|
||||
authentication_classes = (DeviceTokenAuthentication,)
|
||||
|
||||
def post(self, request, format=None):
|
||||
device = request.auth
|
||||
device.api_token = None
|
||||
device.save()
|
||||
device.log_action('pretix.device.revoked', auth=device)
|
||||
|
||||
serializer = DeviceSerializer(device)
|
||||
return Response(serializer.data)
|
||||
@@ -1,5 +1,7 @@
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django.db.models import ProtectedError, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import filters, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -10,20 +12,79 @@ from pretix.api.serializers.event import (
|
||||
TaxRuleSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import Event, ItemCategory, TaxRule
|
||||
from pretix.base.models import (
|
||||
Device, Event, ItemCategory, TaxRule, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class EventFilter(FilterSet):
|
||||
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')
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ['is_public', 'live', 'has_subevents']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = (
|
||||
Q(has_subevents=False) &
|
||||
Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class EventViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = EventSerializer
|
||||
queryset = Event.objects.none()
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'event'
|
||||
permission_classes = (EventCRUDPermission,)
|
||||
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = EventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property')
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = self.request.auth.get_events_with_any_permission()
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = self.request.user.get_events_with_any_permission(self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property'
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
current_live_value = serializer.instance.live
|
||||
@@ -120,9 +181,40 @@ class CloneEventViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class SubEventFilter(FilterSet):
|
||||
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')
|
||||
|
||||
class Meta:
|
||||
model = SubEvent
|
||||
fields = ['active']
|
||||
fields = ['active', 'event__live']
|
||||
|
||||
def ends_after_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
|
||||
)
|
||||
return queryset.filter(expr)
|
||||
|
||||
def is_past_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
def is_future_qs(self, queryset, name, value):
|
||||
expr = Q(
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
|
||||
)
|
||||
if value:
|
||||
return queryset.filter(expr)
|
||||
else:
|
||||
return queryset.exclude(expr)
|
||||
|
||||
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
||||
@@ -132,7 +224,19 @@ class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
|
||||
filterset_class = SubEventFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.subevents.prefetch_related(
|
||||
if getattr(self.request, 'event', None):
|
||||
qs = self.request.event.subevents
|
||||
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.auth.get_events_with_any_permission()
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__in=self.request.user.get_events_with_any_permission()
|
||||
)
|
||||
return qs.prefetch_related(
|
||||
'subeventitem_set', 'subeventitemvariation_set'
|
||||
)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
filterset_class = ItemFilter
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -92,7 +92,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -154,7 +154,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -210,7 +210,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
filterset_class = ItemCategoryFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -264,7 +264,8 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
@@ -307,7 +308,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter,)
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -362,7 +363,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
filterset_class = QuotaFilter
|
||||
ordering_fields = ('id', 'size')
|
||||
ordering = ('id',)
|
||||
permission = 'can_change_items'
|
||||
permission = None
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -25,7 +25,7 @@ from pretix.api.serializers.order import (
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
|
||||
TeamAPIToken,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
@@ -72,13 +72,34 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.orders.prefetch_related(
|
||||
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
|
||||
'positions__answers__question', 'fees', 'payments', 'refunds', 'refunds__payment'
|
||||
qs = self.request.event.orders.prefetch_related(
|
||||
'fees', 'payments', 'refunds', 'refunds__payment'
|
||||
).select_related(
|
||||
'invoice_address'
|
||||
)
|
||||
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
@@ -177,6 +198,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail
|
||||
)
|
||||
@@ -191,7 +213,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
approve_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
)
|
||||
except Quota.QuotaExceededException as e:
|
||||
@@ -210,7 +232,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
deny_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
auth=request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken)) else None,
|
||||
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
|
||||
send_mail=send_mail,
|
||||
comment=comment,
|
||||
)
|
||||
@@ -267,7 +289,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
mark_order_refunded(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
|
||||
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
|
||||
@@ -23,5 +23,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
else:
|
||||
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
|
||||
elif hasattr(self.request.auth, 'organizer_id'):
|
||||
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
|
||||
else:
|
||||
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
|
||||
|
||||
@@ -57,7 +57,7 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
for f in self.fields.values():
|
||||
for k, f in self.fields.items():
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from pretix.base.forms.widgets import (
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
@@ -143,7 +144,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = forms.SplitDateTimeField(
|
||||
field = SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -92,14 +93,20 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
date_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
time_attrs['placeholder'] = now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
def date_placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
def time_placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
date_attrs['placeholder'] = lazy(date_placeholder, str)
|
||||
time_attrs['placeholder'] = lazy(time_placeholder, str)
|
||||
|
||||
widgets = (
|
||||
forms.DateInput(attrs=date_attrs, format=date_format),
|
||||
|
||||
@@ -379,7 +379,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
if self.invoice.internal_reference:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Your reference: {reference}').format(reference=self.invoice.internal_reference),
|
||||
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
|
||||
45
src/pretix/base/migrations/0099_auto_20180912_1035.py
Normal file
45
src/pretix/base/migrations/0099_auto_20180912_1035.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 2.1 on 2018-09-12 10:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.devices
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0098_auto_20180731_1243_squashed_0100_item_require_approval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('device_id', models.PositiveIntegerField()),
|
||||
('unique_serial', models.CharField(default=pretix.base.models.devices.generate_serial, max_length=190, unique=True)),
|
||||
('initialization_token', models.CharField(default=pretix.base.models.devices.generate_initialization_token, max_length=190, unique=True)),
|
||||
('api_token', models.CharField(max_length=190, null=True, unique=True)),
|
||||
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('name', models.CharField(max_length=190, verbose_name='Name')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='Setup date')),
|
||||
('initialized', models.DateTimeField(null=True, verbose_name='Initialization date')),
|
||||
('hardware_brand', models.CharField(blank=True, max_length=190, null=True)),
|
||||
('hardware_model', models.CharField(blank=True, max_length=190, null=True)),
|
||||
('software_brand', models.CharField(blank=True, max_length=190, null=True)),
|
||||
('software_version', models.CharField(blank=True, max_length=190, null=True)),
|
||||
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='device',
|
||||
unique_together={('organizer', 'device_id')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='logentry',
|
||||
name='device',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Device'),
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .devices import Device
|
||||
from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
|
||||
@@ -47,6 +47,7 @@ class LoggingMixin:
|
||||
"""
|
||||
from .log import LogEntry
|
||||
from .event import Event
|
||||
from .devices import Device
|
||||
from pretix.api.models import OAuthAccessToken, OAuthApplication
|
||||
from .organizer import TeamAPIToken
|
||||
from ..notifications import get_all_notification_types
|
||||
@@ -67,6 +68,8 @@ class LoggingMixin:
|
||||
kwargs['oauth_application'] = auth
|
||||
elif isinstance(auth, TeamAPIToken):
|
||||
kwargs['api_token'] = auth
|
||||
elif isinstance(auth, Device):
|
||||
kwargs['device'] = auth
|
||||
elif isinstance(api_token, TeamAPIToken):
|
||||
kwargs['api_token'] = api_token
|
||||
|
||||
@@ -96,4 +99,4 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token')
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
155
src/pretix/base/models/devices.py
Normal file
155
src/pretix/base/models/devices.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import string
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Max
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
|
||||
|
||||
def generate_serial():
|
||||
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
|
||||
while Device.objects.filter(unique_serial=serial).exists():
|
||||
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
|
||||
return serial
|
||||
|
||||
|
||||
def generate_initialization_token():
|
||||
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
while Device.objects.filter(initialization_token=token).exists():
|
||||
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
return token
|
||||
|
||||
|
||||
def generate_api_token():
|
||||
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
while Device.objects.filter(api_token=token).exists():
|
||||
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
return token
|
||||
|
||||
|
||||
class Device(LoggedModel):
|
||||
organizer = models.ForeignKey(
|
||||
'pretixbase.Organizer',
|
||||
on_delete=models.PROTECT,
|
||||
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)
|
||||
api_token = models.CharField(max_length=190, unique=True, null=True)
|
||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||
name = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Name')
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_('Setup date')
|
||||
)
|
||||
initialized = models.DateTimeField(
|
||||
verbose_name=_('Initialization date'),
|
||||
null=True,
|
||||
)
|
||||
hardware_brand = models.CharField(
|
||||
max_length=190,
|
||||
null=True, blank=True
|
||||
)
|
||||
hardware_model = models.CharField(
|
||||
max_length=190,
|
||||
null=True, blank=True
|
||||
)
|
||||
software_brand = models.CharField(
|
||||
max_length=190,
|
||||
null=True, blank=True
|
||||
)
|
||||
software_version = models.CharField(
|
||||
max_length=190,
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('organizer', 'device_id'),)
|
||||
|
||||
def __str__(self):
|
||||
return '#{}: {} ({} {})'.format(
|
||||
self.device_id, self.name, self.hardware_brand, self.hardware_model
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.device_id:
|
||||
self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def permission_set(self) -> set:
|
||||
return {
|
||||
'can_view_orders',
|
||||
'can_change_orders',
|
||||
}
|
||||
|
||||
def get_event_permission_set(self, organizer, event) -> set:
|
||||
"""
|
||||
Gets a set of permissions (as strings) that a token holds for a particular event
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:return: set of permissions
|
||||
"""
|
||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||
event in self.limit_events.all()
|
||||
)
|
||||
return self.permission_set() if has_event_access else set()
|
||||
|
||||
def get_organizer_permission_set(self, organizer) -> set:
|
||||
"""
|
||||
Gets a set of permissions (as strings) that a token holds for a particular organizer
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:return: set of permissions
|
||||
"""
|
||||
return self.permission_set() if self.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||
event in self.limit_events.all()
|
||||
)
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
return has_event_access and any(p in self.permission_set() for p in perm_name)
|
||||
return has_event_access and (not perm_name or perm_name in self.permission_set())
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the organizer ``organizer``.
|
||||
|
||||
:param organizer: The organizer to check
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
|
||||
return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
|
||||
|
||||
def get_events_with_any_permission(self):
|
||||
"""
|
||||
Returns a queryset of events the token has any permissions to.
|
||||
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
if self.all_events:
|
||||
return self.organizer.events.all()
|
||||
else:
|
||||
return self.limit_events.all()
|
||||
@@ -276,12 +276,24 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
return super().presale_has_ended
|
||||
|
||||
def delete_all_orders(self, really=False):
|
||||
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
|
||||
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
OrderPosition.objects.all().delete(order__event=self)
|
||||
OrderFee.objects.all().delete(order__event=self)
|
||||
OrderPayment.objects.all().delete(order__event=self)
|
||||
OrderRefund.objects.all().delete(order__event=self)
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
obj = super().save(*args, **kwargs)
|
||||
self.cache.clear()
|
||||
return obj
|
||||
|
||||
def get_plugins(self) -> "list[str]":
|
||||
def get_plugins(self):
|
||||
"""
|
||||
Returns the names of the plugins activated for this event as a list.
|
||||
"""
|
||||
|
||||
@@ -41,6 +41,7 @@ class LogEntry(models.Model):
|
||||
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
|
||||
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
|
||||
device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT)
|
||||
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
action_type = models.CharField(max_length=255)
|
||||
|
||||
@@ -420,6 +420,20 @@ class Order(LoggedModel):
|
||||
dl_date = dl_date.datetime(self.event)
|
||||
return dl_date
|
||||
|
||||
@property
|
||||
def ticket_download_available(self):
|
||||
return self.event.settings.ticket_download and (
|
||||
self.event.settings.ticket_download_date is None
|
||||
or now() > self.ticket_download_date
|
||||
) and (
|
||||
self.status == Order.STATUS_PAID
|
||||
or (
|
||||
(self.event.settings.ticket_download_pending or self.total == Decimal("0.00")) and
|
||||
self.status == Order.STATUS_PENDING and
|
||||
not self.require_approval
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def payment_term_last(self):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
@@ -496,7 +510,7 @@ class Order(LoggedModel):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None):
|
||||
auth=None, attach_tickets=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -512,6 +526,7 @@ class Order(LoggedModel):
|
||||
:param user: Administrative user who triggered this mail to be sent
|
||||
:param headers: Dictionary with additional mail headers
|
||||
:param sender: Custom email sender.
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
"""
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
|
||||
@@ -525,7 +540,7 @@ class Order(LoggedModel):
|
||||
mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers, sender,
|
||||
invoices=invoices
|
||||
invoices=invoices, attach_tickets=attach_tickets
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -882,6 +897,22 @@ class OrderPayment(models.Model):
|
||||
"""
|
||||
return self.order.event.get_payment_providers().get(self.provider)
|
||||
|
||||
def _mark_paid(self, force, count_waitinglist, user, auth):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||
if not force and can_be_paid is not True:
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
|
||||
self.order.log_action('pretix.event.order.paid', {
|
||||
'provider': self.provider,
|
||||
'info': self.info,
|
||||
'date': self.payment_date,
|
||||
'force': force
|
||||
}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
|
||||
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
|
||||
"""
|
||||
Marks the payment as complete. If possible, this also marks the order as paid if no further
|
||||
@@ -901,7 +932,6 @@ class OrderPayment(models.Model):
|
||||
:type mail_text: str
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
from pretix.base.signals import order_paid
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -928,20 +958,14 @@ class OrderPayment(models.Model):
|
||||
if payment_sum - refund_sum < self.order.total:
|
||||
return
|
||||
|
||||
with self.order.event.lock():
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||
if not force and can_be_paid is not True:
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
|
||||
self.order.log_action('pretix.event.order.paid', {
|
||||
'provider': self.provider,
|
||||
'info': self.info,
|
||||
'date': self.payment_date,
|
||||
'force': force
|
||||
}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
|
||||
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
|
||||
# database transaction is more than enough.
|
||||
with transaction.atomic():
|
||||
self._mark_paid(force, count_waitinglist, user, auth)
|
||||
else:
|
||||
with self.order.event.lock():
|
||||
self._mark_paid(force, count_waitinglist, user, auth)
|
||||
|
||||
invoice = None
|
||||
if invoice_qualified(self.order):
|
||||
@@ -982,7 +1006,8 @@ class OrderPayment(models.Model):
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else []
|
||||
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order paid email could not be sent')
|
||||
|
||||
@@ -58,7 +58,7 @@ class Organizer(LoggedModel):
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
def get_cache(self):
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
Django's built-in cache backends, but puts you into an isolated environment for
|
||||
|
||||
@@ -14,12 +14,14 @@ from django.http import HttpRequest
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries import Countries
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Order, OrderPayment, OrderRefund, Quota,
|
||||
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund,
|
||||
Quota,
|
||||
)
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
@@ -28,7 +30,7 @@ from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.helpers.money import DecimalTextInput
|
||||
from pretix.presale.views import get_cart_total
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,7 +181,7 @@ class BasePaymentProvider:
|
||||
implementation.
|
||||
"""
|
||||
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
|
||||
return OrderedDict([
|
||||
d = OrderedDict([
|
||||
('_enabled',
|
||||
forms.BooleanField(
|
||||
label=_('Enable payment method'),
|
||||
@@ -250,7 +252,20 @@ class BasePaymentProvider:
|
||||
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
|
||||
required=False
|
||||
)),
|
||||
('_restricted_countries',
|
||||
forms.MultipleChoiceField(
|
||||
label=_('Restrict to countries'),
|
||||
choices=Countries(),
|
||||
help_text=_('Only allow choosing this payment provider for invoice addresses in the selected '
|
||||
'countries. If you don\'t select any country, all countries are allowed.'),
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
required=False
|
||||
)),
|
||||
])
|
||||
d['_restricted_countries']._as_type = list
|
||||
return d
|
||||
|
||||
def settings_content_render(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
@@ -350,7 +365,8 @@ class BasePaymentProvider:
|
||||
during checkout, not on retrying.
|
||||
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future
|
||||
and for the _total_max and _total_min requirements to be met.
|
||||
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
|
||||
setting.
|
||||
|
||||
:param total: The total value without the payment method fee, after taxes.
|
||||
|
||||
@@ -371,6 +387,25 @@ class BasePaymentProvider:
|
||||
if self.settings._total_min is not None:
|
||||
pricing = pricing and total >= Decimal(self.settings._total_min)
|
||||
|
||||
def get_invoice_address():
|
||||
if not hasattr(request, '_checkout_flow_invoice_address'):
|
||||
cs = cart_session(request)
|
||||
iapk = cs.get('invoice_address')
|
||||
if not iapk:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
else:
|
||||
try:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
return request._checkout_flow_invoice_address
|
||||
|
||||
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
|
||||
if restricted_countries:
|
||||
ia = get_invoice_address()
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
return timing and pricing
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
|
||||
@@ -503,7 +538,8 @@ class BasePaymentProvider:
|
||||
Will be called to check whether it is allowed to change the payment method of
|
||||
an order to this one.
|
||||
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future.
|
||||
The default implementation checks for the _availability_date setting to be either unset or in the future,
|
||||
as well as for the _total_max, _total_min and _restricted_countries settings.
|
||||
|
||||
:param order: The order object
|
||||
"""
|
||||
@@ -514,6 +550,16 @@ class BasePaymentProvider:
|
||||
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
|
||||
return False
|
||||
|
||||
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
|
||||
if restricted_countries:
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return True
|
||||
else:
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
return self._is_still_available(order=order)
|
||||
|
||||
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
|
||||
|
||||
@@ -161,7 +161,10 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Addon 1\nAddon 2"),
|
||||
"evaluate": lambda op, order, ev: "<br/>".join([
|
||||
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
|
||||
for p in op.addons.select_related('item', 'variation')
|
||||
for p in (
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
else op.addons.select_related('item', 'variation')
|
||||
)
|
||||
])
|
||||
}),
|
||||
("organizer", {
|
||||
|
||||
@@ -200,10 +200,10 @@ def regenerate_invoice(invoice: Invoice):
|
||||
|
||||
|
||||
def generate_invoice(order: Order, trigger_pdf=True):
|
||||
locale = order.event.settings.get('invoice_language')
|
||||
locale = order.event.settings.get('invoice_language', order.event.settings.locale)
|
||||
if locale:
|
||||
if locale == '__user__':
|
||||
locale = order.locale
|
||||
locale = order.locale or order.event.settings.locale
|
||||
|
||||
invoice = Invoice(
|
||||
order=order,
|
||||
|
||||
@@ -14,6 +14,7 @@ from pretix.base.email import ClassicMailRenderer
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
|
||||
from pretix.base.services.invoices import invoice_pdf_task
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -35,7 +36,8 @@ class SendMailException(Exception):
|
||||
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None):
|
||||
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
attach_tickets=False):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -65,6 +67,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
:param invoices: A list of invoices to attach to this email.
|
||||
|
||||
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
|
||||
|
||||
: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.
|
||||
"""
|
||||
@@ -153,7 +157,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
event=event.id if event else None,
|
||||
headers=headers,
|
||||
invoices=[i.pk for i in invoices] if invoices else [],
|
||||
order=order.pk if order else None
|
||||
order=order.pk if order else None,
|
||||
attach_tickets=attach_tickets
|
||||
)
|
||||
|
||||
if invoices:
|
||||
@@ -168,7 +173,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
@app.task
|
||||
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
|
||||
order: int=None) -> bool:
|
||||
order: int=None, attach_tickets=False) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
email.attach_alternative(html, "text/html")
|
||||
@@ -185,6 +190,7 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
||||
except:
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if event:
|
||||
event = Event.objects.get(id=event)
|
||||
backend = event.get_mail_backend()
|
||||
@@ -197,6 +203,15 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
||||
order = event.orders.get(pk=order)
|
||||
except Order.DoesNotExist:
|
||||
order = None
|
||||
else:
|
||||
if attach_tickets:
|
||||
for name, ct in get_tickets_for_order(order):
|
||||
email.attach(
|
||||
name,
|
||||
ct.file.read(),
|
||||
ct.type
|
||||
)
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order)
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,7 +21,7 @@ from pretix.base.i18n import (
|
||||
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
|
||||
OrderPosition, Quota, User, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
@@ -307,7 +307,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -319,6 +319,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_
|
||||
user = User.objects.get(pk=user)
|
||||
if isinstance(api_token, int):
|
||||
api_token = TeamAPIToken.objects.get(pk=api_token)
|
||||
if isinstance(device, int):
|
||||
device = Device.objects.get(pk=device)
|
||||
if isinstance(oauth_application, int):
|
||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
||||
with order.event.lock():
|
||||
@@ -327,7 +329,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application)
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
@@ -631,7 +633,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else []
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
@@ -733,7 +736,8 @@ def send_download_reminders(sender, **kwargs):
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent'
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
@@ -1299,10 +1303,11 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None):
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None):
|
||||
try:
|
||||
try:
|
||||
return _cancel_order(order, user, send_mail, api_token, oauth_application)
|
||||
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -11,10 +12,12 @@ from pretix.base.models import (
|
||||
OrderPosition,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate(order_position: str, provider: str):
|
||||
@@ -97,7 +100,8 @@ def preview(event: int, provider: str):
|
||||
return prov.generate(p)
|
||||
|
||||
|
||||
def get_cachedticket_for_position(pos, identifier):
|
||||
def get_cachedticket_for_position(pos, identifier, generate_async=True):
|
||||
apply_method = 'apply_async' if generate_async else 'apply'
|
||||
try:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=identifier
|
||||
@@ -109,15 +113,20 @@ def get_cachedticket_for_position(pos, identifier):
|
||||
ct = CachedTicket.objects.create(
|
||||
order_position=pos, provider=identifier,
|
||||
extension='', type='', file=None)
|
||||
generate.apply_async(args=(pos.id, identifier))
|
||||
getattr(generate, apply_method)(args=(pos.id, identifier))
|
||||
if not generate_async:
|
||||
ct.refresh_from_db()
|
||||
|
||||
if not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=5):
|
||||
generate.apply_async(args=(pos.id, identifier))
|
||||
getattr(generate, apply_method)(args=(pos.id, identifier))
|
||||
if not generate_async:
|
||||
ct.refresh_from_db()
|
||||
return ct
|
||||
|
||||
|
||||
def get_cachedticket_for_order(order, identifier):
|
||||
def get_cachedticket_for_order(order, identifier, generate_async=True):
|
||||
apply_method = 'apply_async' if generate_async else 'apply'
|
||||
try:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=identifier
|
||||
@@ -129,9 +138,63 @@ def get_cachedticket_for_order(order, identifier):
|
||||
ct = CachedCombinedTicket.objects.create(
|
||||
order=order, provider=identifier,
|
||||
extension='', type='', file=None)
|
||||
generate_order.apply_async(args=(order.id, identifier))
|
||||
getattr(generate_order, apply_method)(args=(order.id, identifier))
|
||||
if not generate_async:
|
||||
ct.refresh_from_db()
|
||||
|
||||
if not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=5):
|
||||
generate_order.apply_async(args=(order.id, identifier))
|
||||
getattr(generate_order, apply_method)(args=(order.id, identifier))
|
||||
if not generate_async:
|
||||
ct.refresh_from_db()
|
||||
return ct
|
||||
|
||||
|
||||
def get_tickets_for_order(order):
|
||||
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
|
||||
if not can_download:
|
||||
return []
|
||||
if not order.ticket_download_available:
|
||||
return []
|
||||
|
||||
providers = [
|
||||
response(order.event)
|
||||
for receiver, response
|
||||
in register_ticket_outputs.send(order.event)
|
||||
]
|
||||
|
||||
tickets = []
|
||||
|
||||
for p in providers:
|
||||
if not p.is_enabled:
|
||||
continue
|
||||
|
||||
if p.multi_download_enabled:
|
||||
try:
|
||||
ct = get_cachedticket_for_order(order, p.identifier, generate_async=False)
|
||||
tickets.append((
|
||||
"{}-{}-{}{}".format(
|
||||
order.event.slug.upper(), order.code, ct.provider, ct.extension,
|
||||
),
|
||||
ct
|
||||
))
|
||||
except:
|
||||
logger.exception('Failed to generate ticket.')
|
||||
else:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to and not order.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
try:
|
||||
ct = get_cachedticket_for_position(pos, p.identifier, generate_async=False)
|
||||
tickets.append((
|
||||
"{}-{}-{}-{}{}".format(
|
||||
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
|
||||
),
|
||||
ct
|
||||
))
|
||||
except:
|
||||
logger.exception('Failed to generate ticket.')
|
||||
|
||||
return tickets
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms.utils import from_current_timezone
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -168,3 +171,17 @@ class SingleLanguageWidget(forms.Select):
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.modify()
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
|
||||
class SplitDateTimeField(forms.SplitDateTimeField):
|
||||
|
||||
def compress(self, data_list):
|
||||
# Differs from the default implementation: If only a time is given and no date, we consider the field empty
|
||||
if data_list:
|
||||
if data_list[0] in self.empty_values:
|
||||
return None
|
||||
if data_list[1] in self.empty_values:
|
||||
raise ValidationError(self.error_messages['invalid_date'], code='invalid_date')
|
||||
result = datetime.datetime.combine(*data_list)
|
||||
return from_current_timezone(result)
|
||||
return None
|
||||
|
||||
@@ -22,7 +22,7 @@ from pretix.base.models.event import EventMetaValue, SubEvent
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, MultipleLanguagesWidget, SingleLanguageWidget, SlugWidget,
|
||||
SplitDateTimePickerWidget,
|
||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
@@ -95,10 +95,10 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
'location',
|
||||
]
|
||||
field_classes = {
|
||||
'date_from': forms.SplitDateTimeField,
|
||||
'date_to': forms.SplitDateTimeField,
|
||||
'presale_start': forms.SplitDateTimeField,
|
||||
'presale_end': forms.SplitDateTimeField,
|
||||
'date_from': SplitDateTimeField,
|
||||
'date_to': SplitDateTimeField,
|
||||
'presale_start': SplitDateTimeField,
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
@@ -229,11 +229,11 @@ class EventUpdateForm(I18nModelForm):
|
||||
'location',
|
||||
]
|
||||
field_classes = {
|
||||
'date_from': forms.SplitDateTimeField,
|
||||
'date_to': forms.SplitDateTimeField,
|
||||
'date_admission': forms.SplitDateTimeField,
|
||||
'presale_start': forms.SplitDateTimeField,
|
||||
'presale_end': forms.SplitDateTimeField,
|
||||
'date_from': SplitDateTimeField,
|
||||
'date_to': SplitDateTimeField,
|
||||
'date_admission': SplitDateTimeField,
|
||||
'presale_start': SplitDateTimeField,
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
@@ -311,7 +311,7 @@ class EventSettingsForm(SettingsForm):
|
||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
widget=forms.NumberInput(),
|
||||
)
|
||||
waiting_list_auto = forms.BooleanField(
|
||||
label=_("Automatic waiting list assignments"),
|
||||
@@ -319,7 +319,7 @@ class EventSettingsForm(SettingsForm):
|
||||
"on the waiting list for that product. If this is not active, mails will not be send automatically "
|
||||
"but you can send them manually via the control panel."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
widget=forms.CheckboxInput(),
|
||||
)
|
||||
attendee_names_asked = forms.BooleanField(
|
||||
label=_("Ask for attendee names"),
|
||||
@@ -497,6 +497,9 @@ class ProviderForm(SettingsForm):
|
||||
elif isinstance(v, (RelativeDateTimeField, RelativeDateField)):
|
||||
v.set_event(self.obj)
|
||||
|
||||
if hasattr(v, '_as_type'):
|
||||
self.initial[k] = self.obj.settings.get(k, as_type=v._as_type)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
enabled = cleaned_data.get(self.settingspref + '_enabled')
|
||||
@@ -916,6 +919,10 @@ class DisplaySettingsForm(SettingsForm):
|
||||
('name_descending', _('Name (descending)')),
|
||||
], # When adding a new ordering, remember to also define it in the event model
|
||||
)
|
||||
meta_noindex = forms.BooleanField(
|
||||
label=_('Ask search engines not to index the ticket shop'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs['obj']
|
||||
@@ -943,12 +950,14 @@ class TicketSettingsForm(SettingsForm):
|
||||
ticket_download_addons = forms.BooleanField(
|
||||
label=_("Offer to download tickets separately for add-on products"),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
|
||||
)
|
||||
ticket_download_nonadm = forms.BooleanField(
|
||||
label=_("Generate tickets for non-admission products"),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
|
||||
)
|
||||
ticket_download_pending = forms.BooleanField(
|
||||
label=_("Offer to download tickets even before an order is paid"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def prepare_fields(self):
|
||||
|
||||
@@ -14,7 +14,7 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn
|
||||
from pretix.base.signals import item_copy_data
|
||||
from pretix.control.forms import SplitDateTimePickerWidget
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
@@ -330,8 +330,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'original_price'
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': forms.SplitDateTimeField,
|
||||
'available_until': forms.SplitDateTimeField,
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.models import Organizer, Team
|
||||
from pretix.base.models import Device, Organizer, Team
|
||||
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.presale.style import get_fonts
|
||||
@@ -107,7 +107,7 @@ class TeamForm(forms.ModelForm):
|
||||
data = super().clean()
|
||||
if self.instance.pk and not data['can_change_teams']:
|
||||
if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter(
|
||||
can_change_teams=True, members__isnull=False
|
||||
can_change_teams=True, members__isnull=False
|
||||
).exists():
|
||||
raise ValidationError(_('The changes could not be saved because there would be no remaining team with '
|
||||
'the permission to change teams and permissions.'))
|
||||
@@ -115,6 +115,23 @@ class TeamForm(forms.ModelForm):
|
||||
return data
|
||||
|
||||
|
||||
class DeviceForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['limit_events'].queryset = organizer.events.all()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['name', 'all_events', 'limit_events']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class OrganizerSettingsForm(SettingsForm):
|
||||
|
||||
organizer_info_text = I18nFormField(
|
||||
|
||||
@@ -12,7 +12,7 @@ from pretix.base.models.event import SubEvent, SubEventMetaValue
|
||||
from pretix.base.models.items import SubEventItem
|
||||
from pretix.base.reldate import RelativeDateTimeField
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.forms import SplitDateTimePickerWidget
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ class SubEventForm(I18nModelForm):
|
||||
'frontpage_text'
|
||||
]
|
||||
field_classes = {
|
||||
'date_from': forms.SplitDateTimeField,
|
||||
'date_to': forms.SplitDateTimeField,
|
||||
'date_admission': forms.SplitDateTimeField,
|
||||
'presale_start': forms.SplitDateTimeField,
|
||||
'presale_end': forms.SplitDateTimeField,
|
||||
'date_from': SplitDateTimeField,
|
||||
'date_to': SplitDateTimeField,
|
||||
'date_admission': SplitDateTimeField,
|
||||
'presale_start': SplitDateTimeField,
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Item, Voucher
|
||||
from pretix.control.forms import SplitDateTimePickerWidget
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||||
from pretix.control.signals import voucher_form_validation
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -34,7 +34,7 @@ class VoucherForm(I18nModelForm):
|
||||
'comment', 'max_usages', 'price_mode', 'subevent'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': forms.SplitDateTimeField,
|
||||
'valid_until': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'valid_until': SplitDateTimePickerWidget(),
|
||||
@@ -190,7 +190,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
'max_usages', 'price_mode', 'subevent'
|
||||
]
|
||||
field_classes = {
|
||||
'valid_until': forms.SplitDateTimeField,
|
||||
'valid_until': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'valid_until': SplitDateTimePickerWidget(),
|
||||
|
||||
@@ -198,6 +198,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.payment.confirmed': _('Payment {local_id} has been confirmed.'),
|
||||
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),
|
||||
'pretix.event.order.payment.started': _('Payment {local_id} has been started.'),
|
||||
'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'),
|
||||
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
||||
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
@@ -274,6 +275,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'),
|
||||
'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been changed on the event date.'),
|
||||
'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'),
|
||||
'pretix.device.created': _('The device has been created.'),
|
||||
'pretix.device.changed': _('The device has been changed.'),
|
||||
'pretix.device.revoked': _('Access of the device has been revoked.'),
|
||||
'pretix.device.initialized': _('The device has been initialized.'),
|
||||
'pretix.device.keyroll': _('The access token of the device has been regenerated.'),
|
||||
'pretix.device.updated': _('The device has notified the server of an hardware or software update.'),
|
||||
}
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% bootstrap_field form.logo_image layout="control" %}
|
||||
{% bootstrap_field form.frontpage_text layout="control" %}
|
||||
{% bootstrap_field form.show_variations_expanded layout="control" %}
|
||||
{% bootstrap_field form.meta_noindex layout="control" %}
|
||||
{% if form.frontpage_subevent_ordering %}
|
||||
{% bootstrap_field form.frontpage_subevent_ordering layout="control" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -167,6 +167,9 @@
|
||||
<br><span class="fa fa-plug fa-fw"></span>
|
||||
{{ log.oauth_application.name }}
|
||||
{% endif %}
|
||||
{% elif log.device %}
|
||||
<span class="fa fa-mobile fa-fw"></span>
|
||||
{{ log.device.name }}
|
||||
{% elif log.api_token %}
|
||||
<span class="fa fa-key fa-fw"></span>
|
||||
{{ log.api_token.name }}
|
||||
@@ -179,6 +182,12 @@
|
||||
</div>
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<li class="list-group-item logentry">
|
||||
<div class="row">
|
||||
<div class="col-lg-2 col-sm-6 col-xs-12">
|
||||
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
<span class="fa fa-clock-o"></span>
|
||||
{{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if log.shredded %}
|
||||
<span class="fa fa-eraser fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
@@ -54,6 +55,9 @@
|
||||
<br><span class="fa fa-plug fa-fw"></span>
|
||||
{{ log.oauth_application.name }}
|
||||
{% endif %}
|
||||
{% elif log.device %}
|
||||
<span class="fa fa-mobile fa-fw"></span>
|
||||
{{ log.device.name }}
|
||||
{% elif log.api_token %}
|
||||
<span class="fa fa-key fa-fw"></span>
|
||||
{{ log.api_token.name }}
|
||||
@@ -66,6 +70,12 @@
|
||||
</div>
|
||||
<div class="col-lg-6 col-sm-12 col-xs-12">
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
{% bootstrap_field form.ticket_download_date layout="control" %}
|
||||
{% bootstrap_field form.ticket_download_addons layout="control" %}
|
||||
{% bootstrap_field form.ticket_download_nonadm layout="control" %}
|
||||
{% bootstrap_field form.ticket_download_pending layout="control" %}
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default ticketoutput-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<span class="fa fa-plug fa-fw"></span>
|
||||
{{ log.oauth_application.name }}
|
||||
{% endif %}
|
||||
{% elif log.device %}
|
||||
<span class="fa fa-mobile fa-fw"></span>
|
||||
{{ log.device.name }}
|
||||
{% elif log.api_token %}
|
||||
<span class="fa fa-key fa-fw"></span>
|
||||
{{ log.api_token.name }}
|
||||
@@ -27,13 +30,18 @@
|
||||
<span class="fa fa-eraser fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "Personal data was cleared from this log entry." %}">
|
||||
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ log.display }}
|
||||
{% if staff_session %}
|
||||
<a href="" class="btn btn-default btn-xs" data-expandlogs data-id="{{ log.pk }}">
|
||||
<span class="fa-eye fa fa-fw"></span>
|
||||
{% trans "Inspect" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'can_change_organizer_settings' in request.orgapermset %}
|
||||
<li {% if "organizer.device" in url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer.devices" organizer=organizer.slug %}">
|
||||
{% trans "Devices" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for nav in nav_organizer %}
|
||||
<li {% if nav.active %}class="active"{% endif %}>
|
||||
<a href="{{ nav.url }}">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<legend>{% trans "Connect to device:" %} {{ device.name }}</legend>
|
||||
|
||||
<div>
|
||||
<ol>
|
||||
<li>{% trans "Open the app that you want to connect and optionally reset it to the original state." %}</li>
|
||||
<li>{% trans "Scan the following configuration code:" %}<br><br>
|
||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||
<br>
|
||||
<strong>{% trans "System URL:" %}</strong> {{ settings.SITE_URL }}<br>
|
||||
<strong>{% trans "Token:" %}</strong> {{ device.initialization_token }}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<strong>
|
||||
{% blocktrans trimmed %}
|
||||
Please note that this is a new feature that currently only works for beta-stage software, such as
|
||||
pretixPOS. pretixdroid 1.x and pretixdesk 0.x are not supported by this feature. Future versions of
|
||||
pretixdroid and pretixdesk will be supported through this menu.
|
||||
{% endblocktrans %}
|
||||
</strong>
|
||||
<br><br>
|
||||
{% blocktrans trimmed %}
|
||||
To set up pretixdroid or pretixdesk, please go to the <strong>Check-in devices</strong> section of an event.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<a href="{% url "control:organizer.devices" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-arrow-left"></i>
|
||||
{% trans "Device overview" %}
|
||||
</a>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/devices.js" %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
{% if device %}
|
||||
<legend>{% trans "Device:" %} {{ device.name }}</legend>
|
||||
{% else %}
|
||||
<legend>{% trans "Connect a new device" %}</legend>
|
||||
{% endif %}
|
||||
<form class="form-horizontal" action="" method="post">
|
||||
{% if device %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-lg-10">
|
||||
{% endif %}
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
{% bootstrap_field form.all_events layout="control" %}
|
||||
{% bootstrap_field form.limit_events layout="control" %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if device %}
|
||||
</div>
|
||||
<div class="col-xs-12 col-lg-2">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Device history" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=device %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<h2>{% trans "Revoke device access:" %} {{ device.name }}</h2>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<strong>{% blocktrans %}Are you sure you want remove access for this device?{% endblocktrans %}</strong>
|
||||
{% trans "All data of this device will stay available, but you can't use the device any more." %}
|
||||
</p>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:organizer.devices" organizer=request.organizer.slug%}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Revoke" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<legend>
|
||||
{% trans "Connected devices" %}
|
||||
</legend>
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
This menu allows you to connect hardware devices such as box office terminals or scanning terminals to
|
||||
your account.
|
||||
{% endblocktrans %}
|
||||
<strong>
|
||||
{% blocktrans trimmed %}
|
||||
Please note that this is a new feature that currently only works for beta-stage software, such as
|
||||
pretixPOS. pretixdroid 1.x and pretixdesk 0.x are not supported by this feature. Future versions of
|
||||
pretixdroid and pretixdesk will be supported through this menu.
|
||||
{% endblocktrans %}
|
||||
</strong>
|
||||
<br><br>
|
||||
{% blocktrans trimmed %}
|
||||
To set up pretixdroid or pretixdesk, please go to the <strong>Check-in devices</strong> section of an event.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% if devices|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't connected any hardware devices yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Device ID" %}</th>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Hardware model" %}</th>
|
||||
<th>{% trans "Software" %}</th>
|
||||
<th>{% trans "Setup date" %}</th>
|
||||
<th>{% trans "Events" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ d.device_id }}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.initialized and not d.api_token %}<del>{% endif %}
|
||||
{{ d.name }}
|
||||
{% if d.initialized and not d.api_token %}</del>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
|
||||
</td>
|
||||
<td>
|
||||
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.initialized %}
|
||||
{{ d.initialized|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% else %}
|
||||
<em>{% trans "Not yet initialized" %}</em>
|
||||
{% endif %}
|
||||
{% if d.initialized and not d.api_token %}
|
||||
<span class="label label-danger">{% trans "Revoked" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.all_events %}
|
||||
{% trans "All" %}
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for e in d.limit_events.all %}
|
||||
<li>
|
||||
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
|
||||
{{ e }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if not d.initialized %}
|
||||
<a href="{% url "control:organizer.device.connect" organizer=request.organizer.slug device=d.id %}"
|
||||
class="btn btn-primary btn-sm"><i class="fa fa-link"></i>
|
||||
{% trans "Connect" %}</a>
|
||||
{% elif d.api_token %}
|
||||
<a href="{% url "control:organizer.device.revoke" organizer=request.organizer.slug device=d.id %}"
|
||||
class="btn btn-default btn-sm">
|
||||
{% trans "Revoke access" %}</a>
|
||||
{% endif %}
|
||||
<a href="{% url "control:organizer.device.edit" organizer=request.organizer.slug device=d.id %}"
|
||||
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Delete vouchers" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete vouchers" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if allowed %}
|
||||
<p>{% blocktrans %}Are you sure you want to delete the following vouchers?{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for s in allowed %}
|
||||
<li>
|
||||
{{ s }}
|
||||
<input type="hidden" name="voucher" value="{{ s.pk }}">
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if forbidden %}
|
||||
<p>{% blocktrans trimmed %}The following vouchers can't be deleted as they already have been redeemed, but they will be set to fully redeemed instead.{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for s in forbidden %}
|
||||
<li>
|
||||
{{ s }}
|
||||
<input type="hidden" name="voucher" value="{{ s.pk }}">
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save" value="delete_confirm" name="action">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -83,56 +83,73 @@
|
||||
class="btn btn-default"><i class="fa fa-download"></i>
|
||||
{% trans "Download list" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Voucher code" %}</th>
|
||||
<th>{% trans "Redemptions" %}</th>
|
||||
<th>{% trans "Expiry" %}</th>
|
||||
<th>{% trans "Tag" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
{% if request.event.has_subevents %}
|
||||
<th>{% trans "Date" context "subevent" %}</th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for v in vouchers %}
|
||||
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="
|
||||
{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
|
||||
</td>
|
||||
<td>{{ v.redeemed }} / {{ v.max_usages }}</td>
|
||||
<td>{{ v.valid_until|date }}</td>
|
||||
<td>
|
||||
{{ v.tag }}
|
||||
</td>
|
||||
<td>
|
||||
{% if v.item %}
|
||||
{{ v.item }}
|
||||
{% if v.variation %}
|
||||
– {{ v.variation }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with quota=v.quota.name %}
|
||||
Any product in quota "{{ quota }}"
|
||||
{% endblocktrans %}
|
||||
<th>
|
||||
{% if "can_change_vouchers" in request.eventpermset %}
|
||||
<input type="checkbox" data-toggle-table />
|
||||
{% endif %}
|
||||
</td>
|
||||
</th>
|
||||
<th>{% trans "Voucher code" %}</th>
|
||||
<th>{% trans "Redemptions" %}</th>
|
||||
<th>{% trans "Expiry" %}</th>
|
||||
<th>{% trans "Tag" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
{% if request.event.has_subevents %}
|
||||
<td>{{ v.subevent.name }} – {{ v.subevent.get_date_range_display }}</td>
|
||||
<th>{% trans "Date" context "subevent" %}</th>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for v in vouchers %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_vouchers" in request.eventpermset %}
|
||||
<input type="checkbox" name="voucher" class="" value="{{ v.pk }}"/>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
|
||||
</td>
|
||||
<td>{{ v.redeemed }} / {{ v.max_usages }}</td>
|
||||
<td>{{ v.valid_until|date }}</td>
|
||||
<td>
|
||||
{{ v.tag }}
|
||||
</td>
|
||||
<td>
|
||||
{% if v.item %}
|
||||
{{ v.item }}
|
||||
{% if v.variation %}
|
||||
– {{ v.variation }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with quota=v.quota.name %}
|
||||
Any product in quota "{{ quota }}"
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if request.event.has_subevents %}
|
||||
<td>{{ v.subevent.name }} – {{ v.subevent.get_date_range_display }}</td>
|
||||
{% endif %}
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if "can_change_vouchers" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-default btn-save" name="action" value="delete">
|
||||
{% trans "Delete selected" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
||||
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
|
||||
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
|
||||
url(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'),
|
||||
url(r'^logdetail/$', global_settings.LogDetailView.as_view(), name='global.logdetail'),
|
||||
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
|
||||
url(r'^sudo/$', user.StartStaffSession.as_view(), name='user.sudo'),
|
||||
url(r'^sudo/stop/$', user.StopStaffSession.as_view(), name='user.sudo.stop'),
|
||||
@@ -68,6 +69,15 @@ urlpatterns = [
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
||||
name='organizer.display'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
|
||||
name='organizer.device.add'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/edit$', organizer.DeviceUpdateView.as_view(),
|
||||
name='organizer.device.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/connect$', organizer.DeviceConnectView.as_view(),
|
||||
name='organizer.device.connect'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/revoke$', organizer.DeviceRevokeView.as_view(),
|
||||
name='organizer.device.revoke'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
|
||||
@@ -169,6 +179,7 @@ urlpatterns = [
|
||||
url(r'^vouchers/add$', vouchers.VoucherCreate.as_view(), name='event.vouchers.add'),
|
||||
url(r'^vouchers/go$', vouchers.VoucherGo.as_view(), name='event.vouchers.go'),
|
||||
url(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
|
||||
url(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
|
||||
name='event.order.transition'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/resend$', orders.OrderResendLink.as_view(),
|
||||
|
||||
@@ -142,21 +142,22 @@ def invite(request, token):
|
||||
|
||||
if request.method == 'POST':
|
||||
form = RegistrationForm(data=request.POST)
|
||||
if form.is_valid():
|
||||
user = User.objects.create_user(
|
||||
form.cleaned_data['email'], form.cleaned_data['password'],
|
||||
locale=request.LANGUAGE_CODE,
|
||||
timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE
|
||||
)
|
||||
user = authenticate(request=request, email=user.email, password=form.cleaned_data['password'])
|
||||
user.log_action('pretix.control.auth.user.created', user=user)
|
||||
auth_login(request, user)
|
||||
request.session['pretix_auth_login_time'] = int(time.time())
|
||||
request.session['pretix_auth_long_session'] = (
|
||||
settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False)
|
||||
)
|
||||
with transaction.atomic():
|
||||
valid = form.is_valid()
|
||||
if valid:
|
||||
user = User.objects.create_user(
|
||||
form.cleaned_data['email'], form.cleaned_data['password'],
|
||||
locale=request.LANGUAGE_CODE,
|
||||
timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE
|
||||
)
|
||||
user = authenticate(request=request, email=user.email, password=form.cleaned_data['password'])
|
||||
user.log_action('pretix.control.auth.user.created', user=user)
|
||||
auth_login(request, user)
|
||||
request.session['pretix_auth_login_time'] = int(time.time())
|
||||
request.session['pretix_auth_long_session'] = (
|
||||
settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
inv.team.members.add(request.user)
|
||||
inv.team.log_action(
|
||||
'pretix.team.member.joined', data={
|
||||
|
||||
@@ -254,7 +254,8 @@ def event_index(request, organizer, event):
|
||||
|
||||
can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders',
|
||||
request=request)
|
||||
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application').order_by('-datetime')
|
||||
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application',
|
||||
'device').order_by('-datetime')
|
||||
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
|
||||
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request):
|
||||
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))
|
||||
|
||||
@@ -875,7 +875,7 @@ class EventLog(EventPermissionRequiredMixin, ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.logentry_set.all().select_related(
|
||||
'user', 'content_type', 'api_token', 'oauth_application'
|
||||
'user', 'content_type', 'api_token', 'oauth_application', 'device'
|
||||
).order_by('-datetime')
|
||||
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
|
||||
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders',
|
||||
@@ -1197,9 +1197,21 @@ class QuickSetupView(FormView):
|
||||
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
|
||||
data={'plugin': 'pretix.plugins.ticketoutputpdf'})
|
||||
plugins_active.append('pretix.plugins.ticketoutputpdf')
|
||||
|
||||
self.request.event.settings.ticket_download = True
|
||||
self.request.event.settings.ticketoutput_pdf__enabled = True
|
||||
|
||||
try:
|
||||
import pretix_passbook # noqa
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
if 'pretix_passbook' not in plugins_active:
|
||||
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
|
||||
data={'plugin': 'pretix_passbook'})
|
||||
plugins_active.append('pretix_passbook')
|
||||
self.request.event.settings.ticketoutput_passbook__enabled = True
|
||||
|
||||
if form.cleaned_data['payment_banktransfer__enabled']:
|
||||
if 'pretix.plugins.banktransfer' not in plugins_active:
|
||||
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import FormView, TemplateView
|
||||
|
||||
from pretix.base.models import LogEntry
|
||||
from pretix.base.services.update_check import check_result_table, update_check
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.control.forms.global_settings import (
|
||||
@@ -62,3 +65,9 @@ class UpdateCheckView(StaffMemberRequiredMixin, FormView):
|
||||
|
||||
class MessageView(TemplateView):
|
||||
template_name = 'pretixcontrol/global_message.html'
|
||||
|
||||
|
||||
class LogDetailView(AdministratorPermissionRequiredMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
le = get_object_or_404(LogEntry, pk=request.GET.get('pk'))
|
||||
return JsonResponse({'data': le.parsed_data})
|
||||
|
||||
@@ -866,7 +866,8 @@ class OrderResendLink(OrderView):
|
||||
email_subject = _('Your order: %(code)s') % {'code': self.order.code}
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.resend', user=self.request.user
|
||||
'pretix.event.order.email.resend', user=self.request.user,
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
messages.error(self.request, _('There was an error sending the mail. Please try again later.'))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.forms import inlineformset_factory
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
@@ -13,14 +17,14 @@ from django.views.generic import (
|
||||
CreateView, DeleteView, DetailView, FormView, ListView, UpdateView,
|
||||
)
|
||||
|
||||
from pretix.base.models import Organizer, Team, TeamInvite, User
|
||||
from pretix.base.models import Device, Organizer, Team, TeamInvite, User
|
||||
from pretix.base.models.event import EventMetaProperty
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.control.forms.filter import OrganizerFilterForm
|
||||
from pretix.control.forms.organizer import (
|
||||
EventMetaPropertyForm, OrganizerDisplaySettingsForm, OrganizerForm,
|
||||
OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||
DeviceForm, EventMetaPropertyForm, OrganizerDisplaySettingsForm,
|
||||
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||
)
|
||||
from pretix.control.permissions import OrganizerPermissionRequiredMixin
|
||||
from pretix.control.signals import nav_organizer
|
||||
@@ -576,3 +580,139 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
|
||||
'organizer': self.request.organizer.slug,
|
||||
'team': self.object.pk
|
||||
})
|
||||
|
||||
|
||||
class DeviceListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||
model = Device
|
||||
template_name = 'pretixcontrol/organizers/devices.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'devices'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.devices.prefetch_related('limit_events')
|
||||
|
||||
|
||||
class DeviceCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||
model = Device
|
||||
template_name = 'pretixcontrol/organizers/device_edit.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
form_class = DeviceForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.device.connect', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'device': self.object.pk
|
||||
})
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.organizer = self.request.organizer
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.device.created', user=self.request.user, data={
|
||||
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
|
||||
for k in form.changed_data
|
||||
})
|
||||
return ret
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes could not be saved.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||
model = Device
|
||||
template_name = 'pretixcontrol/organizers/device_edit.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'device'
|
||||
form_class = DeviceForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device'))
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.devices', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.has_changed():
|
||||
self.object.log_action('pretix.device.changed', user=self.request.user, data={
|
||||
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
|
||||
for k in form.changed_data
|
||||
})
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes could not be saved.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||
model = Device
|
||||
template_name = 'pretixcontrol/organizers/device_connect.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'device'
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device'))
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if 'ajax' in request.GET:
|
||||
return JsonResponse({
|
||||
'initialized': bool(self.object.initialized)
|
||||
})
|
||||
if self.object.initialized:
|
||||
messages.success(request, _('This device has been set up successfully.'))
|
||||
return redirect(reverse('control:organizer.devices', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
}))
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['qrdata'] = json.dumps({
|
||||
'handshake_version': 1,
|
||||
'url': settings.SITE_URL,
|
||||
'token': self.object.initialization_token,
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
class DeviceRevokeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||
model = Device
|
||||
template_name = 'pretixcontrol/organizers/device_revoke.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'device'
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(Device, organizer=self.request.organizer, pk=self.kwargs.get('device'))
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
if not self.object.api_token:
|
||||
messages.success(request, _('This device currently does not have access.'))
|
||||
return redirect(reverse('control:organizer.devices', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
}))
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.object.api_token = None
|
||||
self.object.save()
|
||||
self.object.log_action('pretix.device.revoked', user=self.request.user)
|
||||
messages.success(request, _('Access for this device has been revoked.'))
|
||||
return redirect(reverse('control:organizer.devices', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
}))
|
||||
|
||||
@@ -217,6 +217,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
|
||||
if form in self.cl_formset.deleted_forms:
|
||||
if not form.instance.pk:
|
||||
continue
|
||||
form.instance.checkins.all().delete()
|
||||
form.instance.log_action(action='pretix.event.checkinlist.deleted', user=self.request.user)
|
||||
form.instance.delete()
|
||||
form.instance.pk = None
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import resolve, reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@@ -322,3 +322,41 @@ class VoucherRNG(EventPermissionRequiredMixin, View):
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
|
||||
class VoucherBulkAction(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_change_vouchers'
|
||||
|
||||
@cached_property
|
||||
def objects(self):
|
||||
return self.request.event.vouchers.filter(
|
||||
id__in=self.request.POST.getlist('voucher')
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
if request.POST.get('action') == 'delete':
|
||||
return render(request, 'pretixcontrol/vouchers/delete_bulk.html', {
|
||||
'allowed': self.objects.filter(redeemed=0),
|
||||
'forbidden': self.objects.exclude(redeemed=0),
|
||||
})
|
||||
elif request.POST.get('action') == 'delete_confirm':
|
||||
for obj in self.objects:
|
||||
if obj.allow_delete():
|
||||
obj.log_action('pretix.voucher.deleted', user=self.request.user)
|
||||
obj.delete()
|
||||
else:
|
||||
obj.log_action('pretix.voucher.changed', user=self.request.user, data={
|
||||
'max_usages': min(obj.redeemed, obj.max_usages),
|
||||
'bulk': True
|
||||
})
|
||||
obj.max_usages = min(obj.redeemed, obj.max_usages)
|
||||
obj.save(update_fields=['max_usages'])
|
||||
messages.success(request, _('The selected vouchers have been deleted or disabled.'))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:event.vouchers', kwargs={
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
@@ -99,6 +99,7 @@ class WaitingListView(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
except WaitingListEntry.DoesNotExist:
|
||||
messages.error(request, _('Waiting list entry not found.'))
|
||||
return self._redirect_back()
|
||||
return self._redirect_back()
|
||||
|
||||
def _redirect_back(self):
|
||||
if "next" in self.request.GET and is_safe_url(self.request.GET.get("next"), allowed_hosts=None):
|
||||
|
||||
@@ -27,7 +27,11 @@ def get_sizes(size, imgsize):
|
||||
if crop:
|
||||
wfactor = min(1, size[0] / imgsize[0])
|
||||
hfactor = min(1, size[1] / imgsize[1])
|
||||
if wfactor > hfactor:
|
||||
if wfactor == hfactor:
|
||||
return (int(imgsize[0] * wfactor), int(imgsize[1] * hfactor)), \
|
||||
(0, int((imgsize[1] * wfactor - imgsize[1] * hfactor) / 2),
|
||||
imgsize[0] * hfactor, int((imgsize[1] * wfactor + imgsize[1] * wfactor) / 2))
|
||||
elif wfactor > hfactor:
|
||||
return (int(size[0]), int(imgsize[1] * hfactor)), \
|
||||
(0, int((imgsize[1] * wfactor - size[1]) / 2), size[0], int((imgsize[1] * wfactor + size[1]) / 2))
|
||||
else:
|
||||
@@ -36,7 +40,9 @@ def get_sizes(size, imgsize):
|
||||
else:
|
||||
wfactor = min(1, size[0] / imgsize[0])
|
||||
hfactor = min(1, size[1] / imgsize[1])
|
||||
if wfactor < hfactor:
|
||||
if wfactor == hfactor:
|
||||
return (int(imgsize[0] * hfactor), int(imgsize[1] * wfactor)), None
|
||||
elif wfactor < hfactor:
|
||||
return (size[0], int(imgsize[1] * wfactor)), None
|
||||
else:
|
||||
return (int(imgsize[0] * hfactor), size[1]), None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -184,7 +184,7 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -183,7 +183,7 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-04-24 14:22+0000\n"
|
||||
"Last-Translator: Pernille Thorsen <perth@aarhus.dk>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -195,7 +195,7 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Ingen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-08-11 08:17+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -199,7 +199,7 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Intern einen anderen Namen verwenden"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-08-11 08:20+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
|
||||
@@ -199,7 +199,7 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Intern einen anderen Namen verwenden"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -184,7 +184,7 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-09-10 16:00+0000\n"
|
||||
"Last-Translator: oocf <oswaldocerna@gmail.com>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -200,7 +200,7 @@ msgstr "Todos"
|
||||
msgid "None"
|
||||
msgstr "Ninguno"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Usar un nombre diferente internamente"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: French\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-07-13 06:00+0000\n"
|
||||
"Last-Translator: Claude <superoxyde@laposte.net>\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -200,7 +200,7 @@ msgstr "Tous"
|
||||
msgid "None"
|
||||
msgstr "Aucun"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Utiliser un nom différent en interne"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -183,7 +183,7 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"PO-Revision-Date: 2018-08-20 20:00+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-09-16 19:47+0000\n"
|
||||
"Last-Translator: Maarten van den Berg <maartenberg1@gmail.com>\n"
|
||||
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
"nl/>\n"
|
||||
@@ -16,7 +16,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 3.0.1\n"
|
||||
"X-Generator: Weblate 3.1.1\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
||||
@@ -48,7 +48,7 @@ msgstr "Verbinding maken met Stripe …"
|
||||
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:56
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
msgstr "Totaal"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:28
|
||||
#: pretix/static/pretixbase/js/asynctask.js:42
|
||||
@@ -194,7 +194,7 @@ msgstr "Alle"
|
||||
msgid "None"
|
||||
msgstr "Geen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Gebruik intern een andere naam"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -183,7 +183,7 @@ msgstr ""
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-06-04 19:48+0000\n"
|
||||
"Last-Translator: wallber azevedo pinheiro <wallpinheiro@gmail.com>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
|
||||
@@ -197,7 +197,7 @@ msgstr "Todos"
|
||||
msgid "None"
|
||||
msgstr "Nenhum"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-11 14:43+0000\n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"PO-Revision-Date: 2018-09-03 06:36+0000\n"
|
||||
"Last-Translator: Yunus Fırat Pişkin <firat.piskin@idvlabs.com>\n"
|
||||
"Language-Team: Turkish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -197,7 +197,7 @@ msgstr "Herşey"
|
||||
msgid "None"
|
||||
msgstr "Hiçbiri"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:531
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Dahili olarak farklı bir ad kullan"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.mixins import CreateModelMixin
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.base.models import Device
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
|
||||
from .models import BankImportJob, BankTransaction
|
||||
@@ -68,7 +69,7 @@ class BankImportJobViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return serializer.save()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user)
|
||||
if not perm_holder.has_organizer_permission(request.organizer, 'can_change_orders'):
|
||||
raise PermissionDenied('Invalid set of permissions')
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
@@ -96,15 +96,11 @@ class BankTransfer(BasePaymentProvider):
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def order_control_render(self, request, order) -> str:
|
||||
if order.payment_info:
|
||||
payment_info = json.loads(order.payment_info)
|
||||
else:
|
||||
payment_info = None
|
||||
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
template = get_template('pretixplugins/banktransfer/control.html')
|
||||
ctx = {'request': request, 'event': self.event,
|
||||
'code': self._code(order),
|
||||
'payment_info': payment_info, 'order': order}
|
||||
'code': self._code(payment.order),
|
||||
'payment_info': payment.info_data, 'order': payment.order}
|
||||
return template.render(ctx)
|
||||
|
||||
def _code(self, order):
|
||||
|
||||
@@ -245,6 +245,10 @@ class Paypal(BasePaymentProvider):
|
||||
if payment.state != 'approved':
|
||||
payment_obj.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
payment_obj.save()
|
||||
payment_obj.order.log_action('pretix.event.order.payment.failed', {
|
||||
'local_id': payment.local_id,
|
||||
'provider': payment.provider,
|
||||
})
|
||||
logger.error('Invalid state: %s' % str(payment))
|
||||
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user