Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db67959abf | ||
|
|
f29f615aec | ||
|
|
f1300badb8 | ||
|
|
88a1cddb23 | ||
|
|
d53fbc52d1 | ||
|
|
ec9511c17a | ||
|
|
3f2bb3beae | ||
|
|
c8b96696b0 | ||
|
|
0bb6b53ebc | ||
|
|
7400c2373b | ||
|
|
635329f102 | ||
|
|
b5a2123d1c | ||
|
|
396c558b25 | ||
|
|
60e178d821 | ||
|
|
7769aaccea | ||
|
|
5ff6d0b014 | ||
|
|
7d9a1b5e0c | ||
|
|
127086a50e | ||
|
|
90d14c004f | ||
|
|
8c457c057e | ||
|
|
36215a989a | ||
|
|
74259bd4c6 | ||
|
|
4561b7dce2 | ||
|
|
89d6166dbe | ||
|
|
f451977a04 | ||
|
|
aac05727ed | ||
|
|
c9ae65a9a8 | ||
|
|
f4a9dbb546 | ||
|
|
3eec791584 | ||
|
|
57473da182 | ||
|
|
fd90752a12 | ||
|
|
0d3f5e0c32 | ||
|
|
c98b0aac90 | ||
|
|
3de85975d6 | ||
|
|
962cdef69a | ||
|
|
b301d20488 | ||
|
|
1b2895b0ca | ||
|
|
d6943bb1dd | ||
|
|
51d74503ae | ||
|
|
5eac3cf9cf | ||
|
|
27d6f24c3c | ||
|
|
d929b163db | ||
|
|
571b3fbfa8 | ||
|
|
db05f8eaa3 | ||
|
|
d8eba81efc | ||
|
|
429ef67bbf | ||
|
|
e5d9e69e3e | ||
|
|
dc32bdc474 | ||
|
|
c45b709d54 | ||
|
|
e1be084d98 | ||
|
|
20b8aafe11 | ||
|
|
c719adf235 | ||
|
|
d08a0bdb00 | ||
|
|
8294391ebc | ||
|
|
394c206133 | ||
|
|
2fcd561ff7 | ||
|
|
f95b77fd7a | ||
|
|
7509bf69ca | ||
|
|
d9adec88c8 | ||
|
|
938a1bca0d | ||
|
|
ab757c502c | ||
|
|
6b17388bd8 | ||
|
|
48a933b757 | ||
|
|
6c02bf73b5 | ||
|
|
960d0bcdf2 | ||
|
|
d389e4390f | ||
|
|
55ce83a642 | ||
|
|
300f8f666d | ||
|
|
5d6083dce4 | ||
|
|
82f9f5027f | ||
|
|
4f015f1d96 | ||
|
|
bbe272c35c | ||
|
|
39513448f3 | ||
|
|
bee61bf398 | ||
|
|
010c31cf10 | ||
|
|
d1643b4506 | ||
|
|
623307b348 | ||
|
|
09e8fca132 | ||
|
|
2c96a26d91 | ||
|
|
f639d2aa57 | ||
|
|
5a68eb345f | ||
|
|
603a3d78fc | ||
|
|
cafc6a7226 | ||
|
|
0b068f6d79 | ||
|
|
ec73c916b7 | ||
|
|
110ccb5587 | ||
|
|
d224ae3eb0 | ||
|
|
dd9c0b3a01 | ||
|
|
d2d711c1f8 | ||
|
|
3dd2492926 | ||
|
|
bc1520ec35 | ||
|
|
3033a82c92 | ||
|
|
bb75be7e8e | ||
|
|
b52f2f5a9e | ||
|
|
5bcfb958f0 | ||
|
|
5f52963ce0 | ||
|
|
3f76be2287 | ||
|
|
92aa65a839 | ||
|
|
bd5337a2c2 | ||
|
|
990d5815f2 | ||
|
|
c1d51cc196 | ||
|
|
f5b871f8f5 | ||
|
|
bc6b84f900 | ||
|
|
5ee79c8148 | ||
|
|
e4706dd3ba | ||
|
|
3c59a870e7 | ||
|
|
ae6ad8870d | ||
|
|
07fed0acce | ||
|
|
7dd99f3d18 | ||
|
|
03d8cfb401 | ||
|
|
ccb981e6ce | ||
|
|
984d5c716b | ||
|
|
43121a08bd | ||
|
|
54c7f16c4c | ||
|
|
6cd2674f2a | ||
|
|
602947a3d7 | ||
|
|
5048963aa2 | ||
|
|
8d16e2b59b | ||
|
|
4accbef6a9 | ||
|
|
2e9d95b96a | ||
|
|
03dfd1b96f | ||
|
|
ee1ccb7f01 |
No files matched your search
@@ -3,4 +3,18 @@ src/static/lightbox/* linguist-vendored
|
||||
src/static/typeahead/* linguist-vendored
|
||||
src/static/moment/* linguist-vendored
|
||||
src/static/datetimepicker/* linguist-vendored
|
||||
src/static/colorpicker/* linguist-vendored
|
||||
src/static/fileupload/* linguist-vendored
|
||||
src/static/charts/* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored
|
||||
src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.eot binary
|
||||
*.otf binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.zip binary
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.jpg binary
|
||||
@@ -21,4 +21,6 @@ pretixeu/
|
||||
local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ http {
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
access_log /var/log/nginx/access.log private;
|
||||
error_log /var/log/nginx/error.log;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
@@ -246,11 +246,11 @@ To install a plugin, you need to build your own docker image. To do so, create a
|
||||
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
|
||||
choice)::
|
||||
|
||||
FROM pretix/standalone
|
||||
FROM pretix/standalone:stable
|
||||
USER root
|
||||
RUN pip3 install pretix-passbook
|
||||
USER pretixuser
|
||||
RUN make production
|
||||
RUN cd /pretix/src && make production
|
||||
|
||||
Then, go to that directory and build the image::
|
||||
|
||||
|
||||
@@ -213,6 +213,9 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
ssl_certificate /path/to/cert.chain.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
add_header Referrer-Options same-origin;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8345/;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -55,16 +55,17 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = 'pretix'
|
||||
copyright = '2014-2016, Raphael Michel'
|
||||
copyright = '2014-2017, Raphael Michel'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.0.0'
|
||||
from pretix import __version__
|
||||
version = '.'.join(__version__.split('.')[:2])
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.0.0'
|
||||
release = __version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
||||
@@ -11,7 +11,7 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues
|
||||
:members: periodic_task, event_live_issues, event_copy_data
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -47,7 +47,7 @@ Backend
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, organizer_edit_tabs
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -18,8 +18,9 @@ If you improved pretix in any way, we'd be very happy if you contribute it
|
||||
back to the main code base! The easiest way to do so is to `create a pull request`_
|
||||
on our `GitHub repository`_.
|
||||
|
||||
Before you do so, please `squash all your changes`_ into one single commit. Please
|
||||
use the test suite to check whether your changes break any existing features and run
|
||||
We recommend that you create a feature branch for every issue you work on so the changes can
|
||||
be reviewed individually.
|
||||
Please use the test suite to check whether your changes break any existing features and run
|
||||
the code style checks to confirm you are consistent with pretix's coding style. You'll
|
||||
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
|
||||
|
||||
@@ -34,4 +35,3 @@ Again: If you get stuck, do not hesitate to contact any of us, or Raphael person
|
||||
|
||||
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
|
||||
.. _GitHub repository: https://github.com/pretix/pretix
|
||||
.. _squash all your changes: https://davidwalsh.name/squash-commits-git
|
||||
@@ -20,13 +20,10 @@ Organizers and events
|
||||
.. autoclass:: pretix.base.models.Organizer
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.OrganizerPermission
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Event
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.EventPermission
|
||||
.. autoclass:: pretix.base.models.Team
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.RequiredAction
|
||||
@@ -67,7 +64,7 @@ Carts and Orders
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.QuestionAnswer
|
||||
:members:
|
||||
:members:
|
||||
|
||||
.. autoclass:: pretix.base.models.Checkin
|
||||
:members:
|
||||
@@ -92,5 +89,3 @@ Vouchers
|
||||
|
||||
.. autoclass:: pretix.base.models.Voucher
|
||||
:members:
|
||||
|
||||
.. _cleanerversion: https://github.com/swisscom/cleanerversion
|
||||
@@ -2,7 +2,10 @@ Settings storage
|
||||
================
|
||||
|
||||
pretix is highly configurable and therefore needs to store a lot of per-event and per-organizer settings.
|
||||
Those settings are stored in the database and accessed through a ``SettingsProxy`` instance. You can obtain
|
||||
For this purpose, we use `django-hierarkey`_ which started out as part of pretix and then got refactored into
|
||||
its own library. It has a comprehensive `documentation`_ which you should read if you work with settings in pretix.
|
||||
|
||||
The settings are stored in the database and accessed through a ``HierarkeyProxy`` instance. You can obtain
|
||||
such an instance from any event or organizer model instance by just accessing ``event.settings`` or
|
||||
``organizer.settings``, respectively.
|
||||
|
||||
@@ -17,12 +20,10 @@ includes serializers for serializing the following types:
|
||||
|
||||
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
||||
convenience in templates you can also access settings values at ``settings[name]`` and ``settings.name``.
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsProxy
|
||||
:members: get, set, delete, freeze
|
||||
See the hierarkey `documentation`_ for more information.
|
||||
|
||||
To avoid naming conflicts, plugins are requested to prefix all settings they use with the name of the plugin
|
||||
or something unique, e.g. ``payment.paypal.api_key``. To reduce redundant typing of this prefix, we provide
|
||||
or something unique, e.g. ``payment_paypal_api_key``. To reduce redundant typing of this prefix, we provide
|
||||
another helper class:
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsSandbox
|
||||
@@ -33,10 +34,10 @@ you will just be passed a sandbox object with a prefix generated from your provi
|
||||
Forms
|
||||
-----
|
||||
|
||||
We also provide a base class for forms that allow the modification of settings:
|
||||
Hierarkey also provides a base class for forms that allow the modification of settings. pretix contains a
|
||||
subclass that also adds suport for internationalized fields:
|
||||
|
||||
.. autoclass:: pretix.base.forms.SettingsForm
|
||||
:members: save
|
||||
|
||||
You can simply use it like this::
|
||||
|
||||
@@ -51,3 +52,17 @@ You can simply use it like this::
|
||||
help_text=_("The number of days after placing an order the user has to pay to "
|
||||
"preserve his reservation."),
|
||||
)
|
||||
|
||||
Defaults in plugins
|
||||
-------------------
|
||||
|
||||
Plugins can add custom hardcoded defaults in the following way::
|
||||
|
||||
from pretix.base.settings import settings_hierarkey
|
||||
|
||||
settings_hierarkey.add_default('key', 'value', type)
|
||||
|
||||
Make sure that you include this code in a module that is imported at app loading time.
|
||||
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
@@ -83,6 +83,10 @@ As we did not implement an overall front page yet, you need to go directly to
|
||||
http://localhost:8000/control/ for the admin view or, if you imported the test
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/
|
||||
|
||||
.. note:: If you want the development server to listen on a different interface or
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
`Django's documentation`_ for more options.
|
||||
|
||||
.. _`checksandtests`:
|
||||
|
||||
Code checks and unit tests
|
||||
@@ -148,3 +152,7 @@ To build the documentation, run the following command from the ``doc/`` director
|
||||
make html
|
||||
|
||||
You will now find the generated documentation in the ``doc/_build/html/`` subdirectory.
|
||||
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
@@ -26,8 +26,11 @@ pretix/
|
||||
multidomain/
|
||||
Additional code implementing our customized :ref:`URL handling <urlconf>`.
|
||||
|
||||
static/
|
||||
Contains all static files (CSS, JavaScript, images)
|
||||
static/
|
||||
Contains all static files (CSS, JavaScript, images)
|
||||
|
||||
static/
|
||||
Contains some pretix plugins that ship with pretix itself
|
||||
|
||||
tests/
|
||||
This is the root directory for all test codes. It includes subdirectories ``base``,
|
||||
@@ -37,7 +40,8 @@ tests/
|
||||
|
||||
Language files
|
||||
--------------
|
||||
The language files live in ``locale/*/LC_MESSAGES/``.
|
||||
|
||||
The language files live in ``pretix/locale/*/LC_MESSAGES/``.
|
||||
|
||||
Static files
|
||||
------------
|
||||
@@ -49,27 +53,14 @@ We use libsass as a preprocessor for CSS. Our own sass code is built in the same
|
||||
step as Bootstrap and FontAwesome, so their mixins etc. are fully available.
|
||||
|
||||
pretix.control
|
||||
pretixcontrol has two main SCSS files, ``pretix/control/static/pretixcontrol/scss/main.scss`` and
|
||||
``pretix/control/static/pretixcontrol/scss/auth.scss``, importing everything else.
|
||||
pretixcontrol has two main SCSS files, ``pretix/static/pretixcontrol/scss/main.scss`` and
|
||||
``pretix/static/pretixcontrol/scss/auth.scss``, importing everything else.
|
||||
|
||||
pretix.presale
|
||||
pretixpresale has one main SCSS files, ``pretix/control/static/pretix/presale/scss/main.scss``,
|
||||
pretixpresale has one main SCSS files, ``pretix/pretixpresale/scss/main.scss``,
|
||||
importing everything else.
|
||||
|
||||
3rd-party assets
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
Bootstrap
|
||||
Bootstrap lives vendored at ``static/bootstrap/``
|
||||
|
||||
Font Awesome
|
||||
Font Awesome lives vendored at ``static/fontawesome/``
|
||||
|
||||
jQuery
|
||||
jQuery lives as a single JavaScript file in ``static/jquery/js/``
|
||||
|
||||
jQuery plugin: Django formsets
|
||||
Our own modified version of `django-formset-js`_ is available as an independent
|
||||
django app and installed via ``pip``.
|
||||
|
||||
.. _django-formset-js: https://github.com/pretix/django-formset-js
|
||||
Most client-side 3rd-party assets are vendored in various subdirectories of ``pretix/static``.
|
||||
@@ -6,6 +6,7 @@ Contents:
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
user/index
|
||||
admin/index
|
||||
development/index
|
||||
plugins/index
|
||||
|
||||
@@ -21,6 +21,7 @@ same team:
|
||||
* `Pages`_
|
||||
* `Passbook/Wallet ticket output`_
|
||||
* `Cartshare`_
|
||||
* `Fontpack Free fonts`_
|
||||
|
||||
The following plugins are from independent third-party authors, so we can make
|
||||
no statements about their stability:
|
||||
@@ -34,3 +35,4 @@ no statements about their stability:
|
||||
.. _Pages: https://github.com/pretix/pretix-pages
|
||||
.. _esPass ticket output: https://github.com/esPass/pretix-espass
|
||||
.. _IcePay integration: https://github.com/chotee/pretix-icepay
|
||||
.. _Fontpack Free fonts: https://github.com/pretix/pretix-fontpack-free
|
||||
@@ -4,6 +4,10 @@ pretixdroid HTTP API
|
||||
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
|
||||
uses to communicate with the pretix server.
|
||||
|
||||
.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility
|
||||
guarantees on this API. We will not add features that are not required for the Android App. There will be
|
||||
a proper general-use API for pretix at a later point in time.
|
||||
|
||||
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
|
||||
|
||||
Redeems a ticket, i.e. checks the user in.
|
||||
@@ -19,6 +23,16 @@ uses to communicate with the pretix server.
|
||||
|
||||
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
|
||||
|
||||
You can optionally include the additional parameter ``datetime`` in the body containing an ISO8601-encoded
|
||||
datetime of the entry attempt. If you don't, the current date and time will be used.
|
||||
|
||||
You can optionally include the additional parameter ``force`` to indicate that the request should be logged
|
||||
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
|
||||
|
||||
You can optionally include the additional parameter ``nonce`` with a globally unique random value to identify this
|
||||
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
|
||||
failure.
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -51,9 +65,9 @@ uses to communicate with the pretix server.
|
||||
* ``unknown_ticket`` - Secret does not match a ticket in the database
|
||||
|
||||
:query key: Secret API key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
|
||||
|
||||
@@ -97,6 +111,46 @@ uses to communicate with the pretix server.
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/download/
|
||||
|
||||
Download data for all tickets.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1
|
||||
Host: demo.pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
|
||||
"order": "ABCE6",
|
||||
"item": "Standard ticket",
|
||||
"variation": null,
|
||||
"attendee_name": "Peter Higgs",
|
||||
"redeemed": false,
|
||||
"paid": true
|
||||
},
|
||||
...
|
||||
],
|
||||
"version": 2
|
||||
}
|
||||
|
||||
:query key: Secret API key
|
||||
:statuscode 200: Valid request
|
||||
:statuscode 404: Unknown organizer or event
|
||||
:statuscode 403: Invalid authorization key
|
||||
|
||||
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
|
||||
|
||||
Returns status information, such as the total number of tickets and the
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
User Guide
|
||||
==========
|
||||
|
||||
Contents:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
payments/index
|
||||
@@ -0,0 +1,31 @@
|
||||
.. _`banktransfer`:
|
||||
|
||||
Bank transfer
|
||||
=============
|
||||
|
||||
To accept payments with bank transfer, you only need to fill one important field in pretix' settings: In "Bank
|
||||
account details" you should specify everything one needs to know to transfer money to you, e.g. your IBAN and BIC,
|
||||
the name of your bank and for international transfers, preferably also your address and the bank's address.
|
||||
|
||||
pretix will automatically tell the user to include the order code in the payment reference so incoming transfers can
|
||||
automatically be matched to payments.
|
||||
|
||||
Importing payment data
|
||||
----------------------
|
||||
|
||||
The easiest way to import payment data is to download a CSV file from your online banking. Most banks provide a CSV
|
||||
export of some sort. You can go to "Import bank data" in pretix to upload a new file:
|
||||
|
||||
.. image:: img/bank1.png
|
||||
|
||||
If you upload a file for the first time, pretix will not know what information is contained in which column as every
|
||||
bank builds completely different CSV files. Therefore, pretix will ask you for that information. It will show you the
|
||||
data of the file you imported and ask you to define the column's meanings. You can select one column that contains
|
||||
the payment date and one that contains the paid amount. You can select multiple columns that contain information
|
||||
about the payer or the payment reference. All other columns will be ignored.
|
||||
|
||||
Once you continue, pretix will try to match the payments to the respective orders automatically. It will tell you how
|
||||
many orders could be processed correctly and how many could not. You can then go back to the upload page to see all
|
||||
transfers from your bank statement that are not yet matched to an order. Using the input field and the buttons on the
|
||||
left of each transaction, you can manually enter an order code to match it to or just discard it from the list, e.g.
|
||||
if the transaction is not related to the event at all.
|
||||
@@ -0,0 +1,52 @@
|
||||
Payment method fees
|
||||
===================
|
||||
|
||||
Most external payment providers like PayPal or Stripe charge substantial fees for your service. In general, you have
|
||||
two options to deal with this:
|
||||
|
||||
1. Pay the fees yourself
|
||||
|
||||
2. Add the fees to your customer's total
|
||||
|
||||
The choice totally depends on you and what your customers expect from you. Option two might be appropriate if you
|
||||
offer different payment methods and want to encourage your customers to use the ones that come you cheaper, but you
|
||||
might also decide to go for option one to make it easier for customers who don't have the option.
|
||||
|
||||
If you go for the second option, you can configure pretix to charge the payment method fees to your user. You can
|
||||
define both an absolute fee as well as a percental fee based on the order total. If you do so, there are two
|
||||
different ways in which pretix can calculate the fee. Normally, it is fine to just go with the default setting, but
|
||||
in case you are interested, here are all the details:
|
||||
|
||||
Payment fee calculation
|
||||
-----------------------
|
||||
|
||||
If you configure a fee for a payment method, there are two possible ways for us to calculate this. Let's
|
||||
assume that your payment provider, e.g. PayPal, charges you 5 % fees and you want to charge your users the
|
||||
same 5 %, such that for a ticket with a list price of 100 € you will get your full 100 €.
|
||||
|
||||
**Method A: Calculate the fee from the subtotal and add it to the bill.**
|
||||
|
||||
For a ticket price of 100 €, this will lead to the following calculation:
|
||||
|
||||
============================================== ============
|
||||
Ticket price 100.00 €
|
||||
pretix calculates the fee as 5 % of 100 € +5.00 €
|
||||
Subtotal that will be paid by the customer 105.00 €
|
||||
PayPal calculates its fee as 5 % of 105 € -5.25 €
|
||||
End total that is on your bank account **99.75 €**
|
||||
============================================== ============
|
||||
|
||||
**Method B (default): Calculate the fee from the total value including the fee.**
|
||||
|
||||
For a ticket price of 100 €, this will lead to the following calculation:
|
||||
|
||||
===================================================== =============
|
||||
Ticket price 100.00 €
|
||||
pretix calculates the fee as 100/(100 - 5) % of 100 € +5.26 €
|
||||
Subtotal that will be paid by the customer 105.26 €
|
||||
PayPal calculates its fee as 5 % of 105 € -5.26 €
|
||||
End total that is on your bank account **100.00 €**
|
||||
===================================================== =============
|
||||
|
||||
Due to the various rounding steps performed by pretix and by the payment provider, the end total on
|
||||
your bank account might stil vary by one cent.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,14 @@
|
||||
Accepting payments
|
||||
==================
|
||||
|
||||
Contents:
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
fees
|
||||
paypal
|
||||
stripe
|
||||
banktransfer
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
Payment method overview
|
||||
=======================
|
||||
|
||||
pretix allows you to accept payments using a variety of payment methods to fit the needs of very different events.
|
||||
This page gives you a short overview over them and links to more detailled descriptions in some cases.
|
||||
|
||||
Payment methods are built as pretix plugins. For this reason, you might first need to enable a certain plugin at
|
||||
"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" -> "Payment".
|
||||
|
||||
If you host pretix on your own server, you might need to install a plugin first for some of the payment methods listed
|
||||
on this page as well as for additional ones.
|
||||
|
||||
:ref:`stripe`
|
||||
Stripe is a US-based company that offers you an easy way to accept credit card payments from all over the world.
|
||||
To accept payments with Stripe, you need to have a Stripe merchant account that is easy to create. Click on the link
|
||||
above to get more details about the Stripe integration into pretix.
|
||||
|
||||
:ref:`paypal`
|
||||
If you want to accept online payments via PayPal, you can do so using pretix. You will need a PayPal merchant
|
||||
account and it is a little bit complicated to obtain the required technical details, but we've got you covered.
|
||||
Click on the link above to learn more.
|
||||
|
||||
:ref:`banktransfer`
|
||||
Classical IBAN wire transfers are a common payment method in central Europe that has the large benefit that it
|
||||
often does not cause any additional fees. However, it requires you to invest some more effort as you need to
|
||||
check your bank account for incoming payments regularly. We provide some tools to make this easier for you.
|
||||
|
||||
SEPA debit
|
||||
In some Europen countries, a very popular online payment method is SEPA direct debit. If you want to offer this
|
||||
option in your pretix ticket shop, we provide a convenient plugin that allows users to enter their SEPA bank
|
||||
account details and issue a SEPA mandate. You will then need to regularly download a SEPA XML file from pretix
|
||||
and upload it to your bank's interface to actually perform the debits.
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.. _`paypal`:
|
||||
|
||||
PayPal
|
||||
======
|
||||
|
||||
To integrate PayPal with pretix, you first need to have an active PayPal merchant account. If you do not already have a
|
||||
PayPal account, you can create one on `paypal.com`_.
|
||||
If you look into pretix' settings, you are required to fill in two keys:
|
||||
|
||||
.. image:: img/paypal_pretix.png
|
||||
|
||||
Unfortunately, it is not straightforward how to get those keys from PayPal's website. In order to do so, you
|
||||
need to go to `developer.paypal.com`_ to link the account to your pretix event.
|
||||
Click on "Log In" in the top-right corner and log in with your PayPal account.
|
||||
|
||||
.. image:: img/paypal2.png
|
||||
|
||||
Then, click on "Dashboard" in the top-right corner.
|
||||
|
||||
.. image:: img/paypal3.png
|
||||
|
||||
In the dashboard, scroll down until you see the headline "REST API Apps". Click "Create App".
|
||||
|
||||
.. image:: img/paypal4.png
|
||||
|
||||
Enter any name for the application that helps you to identify it later. Then confirm with "Create App".
|
||||
|
||||
.. image:: img/paypal5.png
|
||||
|
||||
On the next page, before you do anything else, switch the mode on the right to "Live" to get the correct keys.
|
||||
Then, copy the "Client ID" and the "Secret" and enter them into the appropriate fields in the payment settings in
|
||||
pretix.
|
||||
|
||||
.. image:: img/paypal6.png
|
||||
|
||||
Finally, we need to create a webhook. The webhook tells PayPal to notify pretix e.g. if a payment gets cancelled so
|
||||
pretix can cancel the ticket as well. If you have multiple events connected to your PayPal account, you need multiple
|
||||
webhooks. To create one, scroll a bit down and click "Add Webhook".
|
||||
|
||||
.. image:: img/paypal7.png
|
||||
|
||||
Then, enter the webhook URL that you find on the pretix settings page. It should look similar to the one in the
|
||||
screenshot but contain your event name. Tick the box "All events" and save.
|
||||
|
||||
.. image:: img/paypal8.png
|
||||
|
||||
That's it, you are ready to go!
|
||||
|
||||
.. _paypal.com: https://www.paypal.com/webapps/mpp/account-selection
|
||||
.. _developer.paypal.com: https://developer.paypal.com/
|
||||
@@ -0,0 +1,28 @@
|
||||
.. _stripe:
|
||||
|
||||
Stripe
|
||||
======
|
||||
|
||||
To integrate Stripe with pretix, you first need to have an active Stripe merchant account. If you do not already have a
|
||||
Stripe account, you can create one on `stripe.com`_. Then, click on "API" in the left navigation of the Stripe
|
||||
Dashboard. As you can see in the following screenshot, you will be presented with two sets of API keys, one for test
|
||||
and one for live payments. In each set, there is a secret and a publishable keys.
|
||||
|
||||
.. image:: img/stripe1.png
|
||||
|
||||
Choose one of the two sets and copy the two keys to the appropriate fields in pretix' settings. To perform actual
|
||||
payments, you will need to use the live keys, but you can use the test keys to test the payment flow before you go live.
|
||||
In test mode, you cannot use your real credit card, but only `test cards`_ like ``4242424242424242`` that you can
|
||||
find in Stripe's documentation.
|
||||
|
||||
If you want Stripe to notify pretix automatically once a payment gets cancelled, so pretix can cancel the ticket as
|
||||
well, you need to create a so-called webhook. To do so, click "Webhooks" on top of the page in the Stripe dashboard
|
||||
that you are currently on. Then, click "Add endpoint" and enter the URL that you find directly below the key
|
||||
configuration in pretix' settings.
|
||||
|
||||
.. image:: img/stripe2.png
|
||||
|
||||
Again, you can choose between live mode and test mode here.
|
||||
|
||||
.. _stripe.com: https://dashboard.stripe.com/register
|
||||
.. _test cards: https://stripe.com/docs/testing#cards
|
||||
@@ -16,3 +16,5 @@ recursive-include pretix/plugins/statistics/templates *
|
||||
recursive-include pretix/plugins/statistics/static *
|
||||
recursive-include pretix/plugins/stripe/templates *
|
||||
recursive-include pretix/plugins/stripe/static *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/templates *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/static *
|
||||
@@ -23,9 +23,6 @@ user.save()
|
||||
organizer = Organizer.objects.create(
|
||||
name='BigEvents LLC', slug='bigevents'
|
||||
)
|
||||
OrganizerPermission.objects.get_or_create(
|
||||
organizer=organizer, user=user
|
||||
)
|
||||
year = now().year + 1
|
||||
event = Event.objects.create(
|
||||
organizer=organizer, name='Demo Conference {}'.format(year),
|
||||
@@ -33,13 +30,17 @@ event = Event.objects.create(
|
||||
date_from=datetime(year, 9, 4, 17, 0, 0),
|
||||
date_to=datetime(year, 9, 6, 17, 0, 0),
|
||||
)
|
||||
EventPermission.objects.get_or_create(
|
||||
event=event, user=user
|
||||
t = Team.objects.get_or_create(
|
||||
organizer=organizer, name='Admin Team',
|
||||
all_events=True, can_create_events=True, can_change_teams=True,
|
||||
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
|
||||
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
|
||||
)
|
||||
cat_tickets = ItemCategory.objects.create(
|
||||
t.members.add(user)
|
||||
cat_tickets = ItemCategory.all.create(
|
||||
event=event, name='Tickets'
|
||||
)
|
||||
cat_merch = ItemCategory.objects.create(
|
||||
cat_merch = ItemCategory.all.create(
|
||||
event=event, name='Merchandise'
|
||||
)
|
||||
question = Question.objects.create(
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.2.2"
|
||||
__version__ = "1.4.0-dev0"
|
||||
@@ -68,7 +68,9 @@ class JSONExporter(BaseExporter):
|
||||
'variation': position.variation_id,
|
||||
'price': position.price,
|
||||
'attendee_name': position.attendee_name,
|
||||
'attendee_email': position.attendee_email,
|
||||
'secret': position.secret,
|
||||
'addon_to': position.addon_to_id,
|
||||
'answers': [
|
||||
{
|
||||
'question': answer.question_id,
|
||||
|
||||
@@ -4,6 +4,8 @@ from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
from ..signals import register_data_exporters
|
||||
@@ -16,7 +18,11 @@ class MailExporter(BaseExporter):
|
||||
def render(self, form_data: dict):
|
||||
qs = self.event.orders.filter(status__in=form_data['status'])
|
||||
addrs = qs.values('email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs))
|
||||
pos = OrderPosition.objects.filter(
|
||||
order__event=self.event, order__status__in=form_data['status']
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs)
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
return 'pretixemails.txt', 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
|
||||
@@ -59,7 +59,7 @@ class OrderListExporter(BaseExporter):
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'), _('Invoice numbers')
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'),
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -69,6 +69,8 @@ class OrderListExporter(BaseExporter):
|
||||
_('Tax value at {rate} % tax').format(rate=tr),
|
||||
]
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
|
||||
writer.writerow(headers)
|
||||
|
||||
provider_names = {}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import logging
|
||||
|
||||
import i18nfield.forms
|
||||
from django import forms
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
|
||||
|
||||
|
||||
@@ -49,67 +47,22 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class SettingsForm(i18nfield.forms.I18nForm):
|
||||
"""
|
||||
This form is meant to be used for modifying EventSettings or OrganizerSettings. It takes
|
||||
care of loading the current values of the fields and saving the field inputs to the
|
||||
settings storage. It also deals with setting the available languages for internationalized
|
||||
fields.
|
||||
|
||||
:param obj: The event or organizer object which should be used for the settings storage
|
||||
"""
|
||||
|
||||
BOOL_CHOICES = (
|
||||
('False', _('disabled')),
|
||||
('True', _('enabled')),
|
||||
)
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = kwargs.pop('obj', None)
|
||||
self.locales = kwargs.pop('locales', None)
|
||||
kwargs['locales'] = self.obj.settings.get('locales') if self.obj else self.locales
|
||||
self.obj = kwargs.get('obj', None)
|
||||
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
|
||||
kwargs['attribute_name'] = 'settings'
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Performs the save operation
|
||||
"""
|
||||
for name, field in self.fields.items():
|
||||
value = self.cleaned_data[name]
|
||||
if isinstance(value, UploadedFile):
|
||||
# Delete old file
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
|
||||
# Create new file
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, value.name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, value.name.split('.')[-1])
|
||||
newname = default_storage.save(fname, value)
|
||||
value._name = newname
|
||||
self.obj.settings.set(name, value)
|
||||
elif isinstance(value, File):
|
||||
# file is unchanged
|
||||
continue
|
||||
elif isinstance(field, forms.FileField):
|
||||
# file is deleted
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
del self.obj.settings[name]
|
||||
elif value is None:
|
||||
del self.obj.settings[name]
|
||||
elif self.obj.settings.get(name, as_type=type(value)) != value:
|
||||
self.obj.settings.set(name, value)
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
|
||||
return fname
|
||||
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
class PlaceholderValidator(BaseValidator):
|
||||
"""
|
||||
Takes list of allowed placeholders,
|
||||
validates form field by checking for placeholders,
|
||||
which are not presented in taken list.
|
||||
"""
|
||||
|
||||
def __init__(self, limit_value):
|
||||
super().__init__(limit_value)
|
||||
self.limit_value = limit_value
|
||||
|
||||
def __call__(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
for l, v in value.data.items():
|
||||
self.__call__(v)
|
||||
return
|
||||
|
||||
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
|
||||
invalid_placeholders = []
|
||||
for placeholder in data_placeholders:
|
||||
if placeholder not in self.limit_value:
|
||||
invalid_placeholders.append(placeholder)
|
||||
if invalid_placeholders:
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder(s): %(value)s'),
|
||||
code='invalid',
|
||||
params={'value': ", ".join(invalid_placeholders,)})
|
||||
|
||||
def clean(self, x):
|
||||
return x
|
||||
@@ -12,7 +12,12 @@ class Command(BaseCommand):
|
||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
||||
call_command('collectstatic', verbosity=1, interactive=False)
|
||||
call_command('compress', verbosity=1, interactive=False)
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
try:
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
except:
|
||||
# Fails when this is executed without a valid database configuration.
|
||||
# We don't care.
|
||||
pass
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-02 09:04
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
def migrate_global_settings(apps, schema_editor):
|
||||
GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting')
|
||||
GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
|
||||
|
||||
l = []
|
||||
for s in GlobalSetting.objects.all():
|
||||
l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value))
|
||||
|
||||
GlobalSettingsObject_SettingsStore.objects.bulk_create(l)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0051_auto_20170206_2027'), ('pretixbase', '0052_auto_20170324_1506'), ('pretixbase', '0053_auto_20170409_1651'), ('pretixbase', '0054_auto_20170413_1050'), ('pretixbase', '0055_auto_20170413_1537'), ('pretixbase', '0056_auto_20170414_1044'), ('pretixbase', '0057_auto_20170501_2116')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WaitingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
|
||||
('locale', models.CharField(default='en', max_length=190)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
|
||||
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
|
||||
('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')),
|
||||
('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created'],
|
||||
'verbose_name_plural': 'Waiting list entries',
|
||||
'verbose_name': 'Waiting list entry',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='invoice',
|
||||
options={'ordering': ('invoice_no',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='orderposition',
|
||||
options={'ordering': ('positionid', 'id'), 'verbose_name': 'Order position', 'verbose_name_plural': 'Order positions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='max_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought at most this times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum times per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='default_price',
|
||||
field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'),
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='EventSetting',
|
||||
new_name='Event_SettingsStore',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='OrganizerSetting',
|
||||
new_name='Organizer_SettingsStore',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSettingsObject_SettingsStore',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(db_index=True, max_length=255)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=migrate_global_settings,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GlobalSetting',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='min_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought if it is added to the cart at least this many times. If you keep the field empty or set it to 0, there is no special limit for this product.', null=True, verbose_name='Minimum amount per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='currency',
|
||||
field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'), ('AMD', 'AMD - Armenian Dram'), ('ANG', 'ANG - Netherlands Antillean Guilder'), ('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'), ('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'), ('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'), ('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'), ('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'), ('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'), ('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'), ('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'), ('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'), ('BYR', 'BYR - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'), ('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'), ('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'), ('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'), ('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'), ('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'), ('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'), ('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'), ('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'), ('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'), ('EUR', 'EUR - Euro'), ('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'), ('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'), ('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'), ('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'), ('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'), ('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'), ('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'), ('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'), ('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'), ('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'), ('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'), ('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'), ('KGS', 'KGS - Som'), ('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'), ('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'), ('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'), ('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'), ('LBP', 'LBP - Lebanese Pound'), ('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'), ('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'), ('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'), ('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'), ('MMK', 'MMK - Kyat'), ('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'), ('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'), ('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'), ('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'), ('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'), ('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'), ('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'), ('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'), ('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'), ('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'), ('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'), ('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'), ('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'), ('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'), ('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'), ('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'), ('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'), ('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'), ('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'), ('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'), ('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'), ('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'), ('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'), ('TTD', 'TTD - Trinidad and Tobago Dollar'), ('TWD', 'TWD - New Taiwan Dollar'), ('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'), ('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'), ('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'), ('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'), ('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'), ('XAG', 'XAG - Silver'), ('XAU', 'XAU - Gold'), ('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'), ('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'), ('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'), ('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'), ('XCD', 'XCD - EasLine truncated
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='max_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought at most this many times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum amount per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalsettingsobject_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemAddOn',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
|
||||
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='is_addon',
|
||||
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='free_price',
|
||||
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='addon_category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='base_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='itemaddon',
|
||||
unique_together=set([('base_item', 'addon_category')]),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='itemaddon',
|
||||
options={'ordering': ('position', 'pk')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='position',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='description',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-27 09:11
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.organizer
|
||||
|
||||
|
||||
def create_teams(apps, schema_editor):
|
||||
Event = apps.get_model('pretixbase', 'Event')
|
||||
Organizer = apps.get_model('pretixbase', 'Organizer')
|
||||
Team = apps.get_model('pretixbase', 'Team')
|
||||
TeamInvite = apps.get_model('pretixbase', 'TeamInvite')
|
||||
EventPermission = apps.get_model('pretixbase', 'EventPermission')
|
||||
OrganizerPermission = apps.get_model('pretixbase', 'OrganizerPermission')
|
||||
|
||||
for o in Organizer.objects.prefetch_related('events'):
|
||||
for e in o.events.all():
|
||||
teams = {}
|
||||
|
||||
for p in e.user_perms.all():
|
||||
pkey = (p.can_change_settings, p.can_change_items, p.can_view_orders,
|
||||
p.can_change_permissions, p.can_change_orders, p.can_view_vouchers,
|
||||
p.can_change_vouchers)
|
||||
if pkey not in teams:
|
||||
team = Team()
|
||||
team.can_change_event_settings = p.can_change_settings
|
||||
team.can_change_items = p.can_change_items
|
||||
team.can_view_orders = p.can_view_orders
|
||||
team.can_change_orders = p.can_change_orders
|
||||
team.can_view_vouchers = p.can_view_vouchers
|
||||
team.can_change_vouchers = p.can_change_vouchers
|
||||
team.organizer = o
|
||||
team.name = '{} Team {}'.format(
|
||||
str(e.name), len(teams) + 1
|
||||
)
|
||||
team.save()
|
||||
team.limit_events.add(e)
|
||||
|
||||
teams[pkey] = team
|
||||
|
||||
if p.user:
|
||||
teams[pkey].members.add(p.user)
|
||||
else:
|
||||
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
|
||||
|
||||
teams = {}
|
||||
for p in o.user_perms.all():
|
||||
pkey = (p.can_create_events, p.can_change_permissions)
|
||||
if pkey not in teams:
|
||||
team = Team()
|
||||
team.can_change_organizer_settings = True
|
||||
team.can_create_events = p.can_create_events
|
||||
team.can_change_teams = p.can_change_permissions
|
||||
team.organizer = o
|
||||
team.name = '{} Team {}'.format(
|
||||
str(o.name), len(teams) + 1
|
||||
)
|
||||
team.save()
|
||||
teams[pkey] = team
|
||||
|
||||
if p.user:
|
||||
teams[pkey].members.add(p.user)
|
||||
else:
|
||||
teams[pkey].invites.create(email=p.invite_email, token=p.invite_token)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20170206_2027_squashed_0057_auto_20170501_2116'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=190, verbose_name='Team name')),
|
||||
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('can_create_events', models.BooleanField(default=False, verbose_name='Can create events')),
|
||||
('can_change_teams', models.BooleanField(default=False, verbose_name='Can change permissions')),
|
||||
('can_change_organizer_settings', models.BooleanField(default=False, verbose_name='Can change organizer settings')),
|
||||
('can_change_event_settings', models.BooleanField(default=False, verbose_name='Can change event settings')),
|
||||
('can_change_items', models.BooleanField(default=False, verbose_name='Can change product settings')),
|
||||
('can_view_orders', models.BooleanField(default=False, verbose_name='Can view orders')),
|
||||
('can_change_orders', models.BooleanField(default=False, verbose_name='Can change orders')),
|
||||
('can_view_vouchers', models.BooleanField(default=False, verbose_name='Can view vouchers')),
|
||||
('can_change_vouchers', models.BooleanField(default=False, verbose_name='Can change vouchers')),
|
||||
('limit_events', models.ManyToManyField(to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('members', models.ManyToManyField(related_name='teams', to=settings.AUTH_USER_MODEL, verbose_name='Team members')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='pretixbase.Organizer')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Teams',
|
||||
'verbose_name': 'Team',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamInvite',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||
('token', models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='pretixbase.Team')),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
create_teams, migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-09 16:51
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_global_settings(apps, schema_editor):
|
||||
GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting')
|
||||
GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
|
||||
|
||||
l = []
|
||||
for s in GlobalSetting.objects.all():
|
||||
l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value))
|
||||
|
||||
GlobalSettingsObject_SettingsStore.objects.bulk_create(l)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0052_auto_20170324_1506'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='EventSetting',
|
||||
new_name='Event_SettingsStore',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='OrganizerSetting',
|
||||
new_name='Organizer_SettingsStore',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSettingsObject_SettingsStore',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(db_index=True, max_length=255)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_global_settings, migrations.RunPython.noop
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GlobalSetting',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-13 10:50
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0053_auto_20170409_1651'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='min_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought if it is added to the cart at least this many times. If you keep the field empty or set it to 0, there is no special limit for this product.', null=True, verbose_name='Minimum amount per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='currency',
|
||||
field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'), ('AMD', 'AMD - Armenian Dram'), ('ANG', 'ANG - Netherlands Antillean Guilder'), ('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'), ('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'), ('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'), ('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'), ('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'), ('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'), ('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'), ('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'), ('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'), ('BYR', 'BYR - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'), ('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'), ('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'), ('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'), ('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'), ('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'), ('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'), ('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'), ('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'), ('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'), ('EUR', 'EUR - Euro'), ('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'), ('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'), ('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'), ('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'), ('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'), ('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'), ('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'), ('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'), ('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'), ('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'), ('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'), ('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'), ('KGS', 'KGS - Som'), ('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'), ('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'), ('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'), ('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'), ('LBP', 'LBP - Lebanese Pound'), ('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'), ('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'), ('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'), ('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'), ('MMK', 'MMK - Kyat'), ('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'), ('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'), ('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'), ('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'), ('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'), ('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'), ('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'), ('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'), ('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'), ('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'), ('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'), ('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'), ('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'), ('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'), ('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'), ('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'), ('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'), ('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'), ('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'), ('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'), ('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'), ('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'), ('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'), ('TTD', 'TTD - Trinidad and Tobago Dollar'), ('TWD', 'TWD - New Taiwan Dollar'), ('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'), ('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'), ('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'), ('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'), ('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'), ('XAG', 'XAG - Silver'), ('XAU', 'XAU - Gold'), ('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'), ('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'), ('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'), ('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'), ('XCD', 'XCD - EasLine truncated
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='max_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought at most this many times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum amount per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-13 15:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0054_auto_20170413_1050'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalsettingsobject_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-14 10:44
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0055_auto_20170413_1537'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemAddOn',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
|
||||
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='is_addon',
|
||||
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='free_price',
|
||||
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='addon_category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='base_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='itemaddon',
|
||||
unique_together=set([('base_item', 'addon_category')]),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-01 21:16
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0056_auto_20170414_1044'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='itemaddon',
|
||||
options={'ordering': ('position', 'pk')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='position',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='description',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-29 10:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0052_team_teaminvite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='eventpermission',
|
||||
name='event',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='eventpermission',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizerpermission',
|
||||
name='organizer',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizerpermission',
|
||||
name='user',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='event',
|
||||
name='permitted',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='organizer',
|
||||
name='permitted',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='can_change_teams',
|
||||
field=models.BooleanField(default=False, verbose_name='Can change teams and permissions'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='team',
|
||||
name='limit_events',
|
||||
field=models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='EventPermission',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='OrganizerPermission',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-04 07:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0058_auto_20170429_1020'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='nonce',
|
||||
field=models.CharField(blank=True, max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-10 10:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0059_checkin_nonce'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='date_admission',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Admission time'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,15 @@
|
||||
from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin
|
||||
from .event import (
|
||||
Event, EventLock, EventPermission, EventSetting, RequiredAction,
|
||||
Event, Event_SettingsStore, EventLock, RequiredAction,
|
||||
generate_invite_token,
|
||||
)
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
itempicture_upload_to,
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .orders import (
|
||||
@@ -17,6 +18,6 @@ from .orders import (
|
||||
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
||||
generate_secret,
|
||||
)
|
||||
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
||||
from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
@@ -1,9 +1,12 @@
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser, BaseUserManager, PermissionsMixin,
|
||||
)
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
|
||||
@@ -81,6 +84,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._teamcache = {}
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("User")
|
||||
verbose_name_plural = _("Users")
|
||||
@@ -147,6 +154,103 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return LogEntry.objects.filter(content_type=ContentType.objects.get_for_model(User),
|
||||
object_id=self.pk)
|
||||
|
||||
def _get_teams_for_organizer(self, organizer):
|
||||
if 'o{}'.format(organizer.pk) not in self._teamcache:
|
||||
self._teamcache['o{}'.format(organizer.pk)] = list(self.teams.filter(organizer=organizer))
|
||||
return self._teamcache['o{}'.format(organizer.pk)]
|
||||
|
||||
def _get_teams_for_event(self, organizer, event):
|
||||
if 'e{}'.format(event.pk) not in self._teamcache:
|
||||
self._teamcache['e{}'.format(event.pk)] = list(self.teams.filter(organizer=organizer).filter(
|
||||
Q(all_events=True) | Q(limit_events=event)
|
||||
))
|
||||
return self._teamcache['e{}'.format(event.pk)]
|
||||
|
||||
class SuperuserPermissionSet:
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]:
|
||||
"""
|
||||
Gets a set of permissions (as strings) that a user holds for a particular event
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
|
||||
a in b always returns true).
|
||||
"""
|
||||
if self.is_superuser:
|
||||
return self.SuperuserPermissionSet()
|
||||
|
||||
teams = self._get_teams_for_event(organizer, event)
|
||||
return set.union(*[t.permission_set() for t in teams])
|
||||
|
||||
def get_organizer_permission_set(self, organizer) -> Union[set, SuperuserPermissionSet]:
|
||||
"""
|
||||
Gets a set of permissions (as strings) that a user holds for a particular organizer
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where
|
||||
a in b always returns true).
|
||||
"""
|
||||
if self.is_superuser:
|
||||
return self.SuperuserPermissionSet()
|
||||
|
||||
teams = self._get_teams_for_organizer(organizer)
|
||||
return set.union(*[t.permission_set() for t in teams])
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None) -> bool:
|
||||
"""
|
||||
Checks if this user is part of any 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``
|
||||
:return: bool
|
||||
"""
|
||||
if self.is_superuser:
|
||||
return True
|
||||
teams = self._get_teams_for_event(organizer, event)
|
||||
if teams:
|
||||
self._teamcache['e{}'.format(event.pk)] = teams
|
||||
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None):
|
||||
"""
|
||||
Checks if this user is part of any 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``
|
||||
:return: bool
|
||||
"""
|
||||
if self.is_superuser:
|
||||
return True
|
||||
teams = self._get_teams_for_organizer(organizer)
|
||||
if teams:
|
||||
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_events_with_any_permission(self):
|
||||
"""
|
||||
Returns a queryset of events the user has any permissions to.
|
||||
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
from .event import Event
|
||||
|
||||
if self.is_superuser:
|
||||
return Event.objects.all()
|
||||
|
||||
return Event.objects.filter(
|
||||
Q(organizer_id__in=self.teams.filter(all_events=True).values_list('organizer', flat=True))
|
||||
| Q(id__in=self.teams.values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
|
||||
class U2FDevice(Device):
|
||||
json_data = models.TextField()
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import checks
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
@@ -34,7 +35,6 @@ def cached_file_delete(sender, instance, **kwargs):
|
||||
|
||||
|
||||
class LoggingMixin:
|
||||
|
||||
def log_action(self, action, data=None, user=None):
|
||||
"""
|
||||
Create a LogEntry object that is related to this object.
|
||||
@@ -59,7 +59,6 @@ class LoggingMixin:
|
||||
|
||||
|
||||
class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -74,3 +73,38 @@ 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')
|
||||
|
||||
|
||||
class EventBoundModelMixin:
|
||||
event_lookup = 'event'
|
||||
|
||||
@classmethod
|
||||
def for_event(cls, event):
|
||||
return cls.all.filter(**{
|
||||
cls.event_lookup: event
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def check(cls, **kwargs):
|
||||
try:
|
||||
errors = super(cls).check(**kwargs)
|
||||
except AttributeError:
|
||||
errors = []
|
||||
if hasattr(cls, 'objects'):
|
||||
errors.append(
|
||||
checks.Error(
|
||||
'Default model manager "objects" defined.',
|
||||
hint='Replace the objects manager by a manager called "all".',
|
||||
obj=cls,
|
||||
id='pretixbase.E001',
|
||||
)
|
||||
)
|
||||
if not hasattr(cls, 'all'):
|
||||
errors.append(
|
||||
checks.Error(
|
||||
'Model manager "all" not defined.',
|
||||
obj=cls,
|
||||
id='pretixbase.E002',
|
||||
)
|
||||
)
|
||||
return errors
|
||||
@@ -8,3 +8,4 @@ class Checkin(models.Model):
|
||||
"""
|
||||
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
|
||||
datetime = models.DateTimeField(default=now)
|
||||
nonce = models.CharField(max_length=190, null=True, blank=True)
|
||||
@@ -11,22 +11,20 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.daterange import daterange
|
||||
|
||||
from .auth import User
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer
|
||||
from .settings import EventSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(LoggedModel):
|
||||
"""
|
||||
This model represents an event. An event is anything you can buy
|
||||
@@ -59,6 +57,7 @@ class Event(LoggedModel):
|
||||
"""
|
||||
|
||||
settings_namespace = 'event'
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
@@ -79,14 +78,15 @@ class Event(LoggedModel):
|
||||
verbose_name=_("Short form"),
|
||||
)
|
||||
live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
|
||||
permitted = models.ManyToManyField(User, through='EventPermission',
|
||||
related_name="events", )
|
||||
currency = models.CharField(max_length=10,
|
||||
verbose_name=_("Default currency"),
|
||||
choices=CURRENCY_CHOICES,
|
||||
default=settings.DEFAULT_CURRENCY)
|
||||
date_from = models.DateTimeField(verbose_name=_("Event start time"))
|
||||
date_to = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_("Event end time"))
|
||||
date_admission = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_("Admission time"))
|
||||
is_public = models.BooleanField(default=False,
|
||||
verbose_name=_("Visible in public lists"),
|
||||
help_text=_("If selected, this event may show up on the ticket system's start page "
|
||||
@@ -101,7 +101,7 @@ class Event(LoggedModel):
|
||||
verbose_name=_("Start of presale"),
|
||||
help_text=_("No products will be sold before this date."),
|
||||
)
|
||||
location = I18nCharField(
|
||||
location = I18nTextField(
|
||||
null=True, blank=True,
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
@@ -139,7 +139,7 @@ class Event(LoggedModel):
|
||||
return []
|
||||
return self.plugins.split(",")
|
||||
|
||||
def get_date_from_display(self, tz=None) -> str:
|
||||
def get_date_from_display(self, tz=None, show_times=True) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date of the event with respect
|
||||
to the current locale and to the ``show_times`` setting.
|
||||
@@ -147,7 +147,17 @@ class Event(LoggedModel):
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz),
|
||||
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
|
||||
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT"
|
||||
)
|
||||
|
||||
def get_time_from_display(self, tz=None) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start time of the event, ignoring
|
||||
the ``show_times`` setting.
|
||||
"""
|
||||
tz = tz or pytz.timezone(self.settings.timezone)
|
||||
return _date(
|
||||
self.date_from.astimezone(tz), "TIME_FORMAT"
|
||||
)
|
||||
|
||||
def get_date_to_display(self, tz=None) -> str:
|
||||
@@ -181,17 +191,6 @@ class Event(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this event's settings.
|
||||
"""
|
||||
try:
|
||||
return SettingsProxy(self, type=EventSetting, parent=self.organizer)
|
||||
except Organizer.DoesNotExist:
|
||||
# Should only happen when creating new events
|
||||
return SettingsProxy(self, type=EventSetting)
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
@@ -235,12 +234,14 @@ class Event(LoggedModel):
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemCategory, Item, Question, Quota
|
||||
from . import ItemAddOn, Item, Question, Quota
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.save()
|
||||
|
||||
category_map = {}
|
||||
for c in ItemCategory.objects.filter(event=other):
|
||||
for c in other.categories.all():
|
||||
category_map[c.pk] = c
|
||||
c.pk = None
|
||||
c.event = self
|
||||
@@ -264,6 +265,12 @@ class Event(LoggedModel):
|
||||
v.item = i
|
||||
v.save()
|
||||
|
||||
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
|
||||
ia.pk = None
|
||||
ia.base_item = item_map[ia.base_item.pk]
|
||||
ia.addon_category = category_map[ia.addon_category.pk]
|
||||
ia.save()
|
||||
|
||||
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
@@ -271,7 +278,8 @@ class Event(LoggedModel):
|
||||
q.event = self
|
||||
q.save()
|
||||
for i in items:
|
||||
q.items.add(item_map[i.pk])
|
||||
if i.pk in item_map:
|
||||
q.items.add(item_map[i.pk])
|
||||
for v in vars:
|
||||
q.variations.add(variation_map[v.pk])
|
||||
|
||||
@@ -288,7 +296,7 @@ class Event(LoggedModel):
|
||||
o.question = q
|
||||
o.save()
|
||||
|
||||
for s in EventSetting.objects.filter(object=other):
|
||||
for s in other.settings._objects.all():
|
||||
s.object = self
|
||||
s.pk = None
|
||||
if s.value.startswith('file://'):
|
||||
@@ -301,74 +309,13 @@ class Event(LoggedModel):
|
||||
s.value = 'file://' + newname
|
||||
s.save()
|
||||
|
||||
event_copy_data.send(sender=self, other=other)
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
|
||||
class EventPermission(models.Model):
|
||||
"""
|
||||
The relation between an Event and a User who has permissions to
|
||||
access an event.
|
||||
|
||||
:param event: The event this permission refers to
|
||||
:type event: Event
|
||||
:param user: The user this permission set applies to
|
||||
:type user: User
|
||||
:param can_change_settings: If ``True``, the user can change all basic settings for this event.
|
||||
:type can_change_settings: bool
|
||||
:param can_change_items: If ``True``, the user can change and add items and related objects for this event.
|
||||
:type can_change_items: bool
|
||||
:param can_view_orders: If ``True``, the user can inspect details of all orders.
|
||||
:type can_view_orders: bool
|
||||
:param can_change_orders: If ``True``, the user can change details of orders
|
||||
:type can_change_orders: bool
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True)
|
||||
invite_email = models.EmailField(null=True, blank=True)
|
||||
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
|
||||
can_change_settings = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change event settings")
|
||||
)
|
||||
can_change_items = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change product settings")
|
||||
)
|
||||
can_view_orders = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can view orders")
|
||||
)
|
||||
can_change_permissions = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change permissions")
|
||||
)
|
||||
can_change_orders = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change orders")
|
||||
)
|
||||
can_view_vouchers = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can view vouchers")
|
||||
)
|
||||
can_change_vouchers = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change vouchers")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Event permission")
|
||||
verbose_name_plural = _("Event permissions")
|
||||
|
||||
def __str__(self):
|
||||
return _("%(name)s on %(object)s") % {
|
||||
'name': str(self.user),
|
||||
'object': str(self.event),
|
||||
}
|
||||
|
||||
|
||||
class EventLock(models.Model):
|
||||
event = models.CharField(max_length=36, primary_key=True)
|
||||
date = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from typing import Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils.functional import cached_property
|
||||
@@ -13,12 +14,12 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.base import EventBoundModelMixin, LoggedModel
|
||||
|
||||
from .event import Event
|
||||
|
||||
|
||||
class ItemCategory(LoggedModel):
|
||||
class ItemCategory(EventBoundModelMixin, LoggedModel):
|
||||
"""
|
||||
Items can be sorted into these categories.
|
||||
|
||||
@@ -44,6 +45,15 @@ class ItemCategory(LoggedModel):
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
is_addon = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Products in this category are add-on products'),
|
||||
help_text=_('If selected, the products belonging to this category are not for sale on their own. They can '
|
||||
'only be bought in combination with a product that has this category configured as a possible '
|
||||
'source for add-ons.')
|
||||
)
|
||||
|
||||
all = models.Manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product category")
|
||||
@@ -51,6 +61,8 @@ class ItemCategory(LoggedModel):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
def __str__(self):
|
||||
if self.is_addon:
|
||||
return _('{category} (Add-On products)').format(category=str(self.name))
|
||||
return str(self.name)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
@@ -113,6 +125,8 @@ class Item(LoggedModel):
|
||||
:type allow_cancel: bool
|
||||
:param max_per_order: Maximum number of times this item can be in an order. None for unlimited.
|
||||
:type max_per_order: int
|
||||
:param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited.
|
||||
:type min_per_order: int
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -153,7 +167,8 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Free price input"),
|
||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||
"additional donations for your event.")
|
||||
"additional donations for your event. This is currently not supported for products that are "
|
||||
"bought as an add-on to other products.")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
verbose_name=_("Taxes included in percent"),
|
||||
@@ -205,6 +220,12 @@ class Item(LoggedModel):
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
|
||||
'the field empty or set it to 0, there is no special limit for this product.')
|
||||
)
|
||||
max_per_order = models.IntegerField(
|
||||
verbose_name=_('Maximum amount per order'),
|
||||
null=True, blank=True,
|
||||
@@ -212,6 +233,8 @@ class Item(LoggedModel):
|
||||
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
||||
'number of items in the whole order applies regardless.')
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/views/item.py if applicable.
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product")
|
||||
@@ -289,6 +312,8 @@ class ItemVariation(models.Model):
|
||||
:type item: Item
|
||||
:param value: A string defining this variation
|
||||
:type value: str
|
||||
:param description: A short description
|
||||
:type description: str
|
||||
:param active: Whether this variation is being sold.
|
||||
:type active: bool
|
||||
:param default_price: This variation's default price
|
||||
@@ -306,6 +331,11 @@ class ItemVariation(models.Model):
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
)
|
||||
description = I18nTextField(
|
||||
verbose_name=_("Description"),
|
||||
help_text=_("This is shown below the variation name in lists."),
|
||||
null=True, blank=True,
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
@@ -369,6 +399,43 @@ class ItemVariation(models.Model):
|
||||
return self.position < other.position
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
An instance of this model indicates that buying a ticket of the time ``base_item``
|
||||
allows you to add up to ``max_count`` items from the category ``addon_category``
|
||||
to your order that will be associated with the base item.
|
||||
"""
|
||||
base_item = models.ForeignKey(
|
||||
Item,
|
||||
related_name='addons'
|
||||
)
|
||||
addon_category = models.ForeignKey(
|
||||
ItemCategory,
|
||||
related_name='addon_to',
|
||||
verbose_name=_('Category')
|
||||
)
|
||||
min_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Minimum number')
|
||||
)
|
||||
max_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Maximum number')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('base_item', 'addon_category'),)
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def clean(self):
|
||||
if self.max_count < self.min_count:
|
||||
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
@@ -623,6 +690,7 @@ class Quota(LoggedModel):
|
||||
func = 'GREATEST'
|
||||
|
||||
return Voucher.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(block_quota=True) &
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
|
||||
Q(Q(self._position_lookup) | Q(quota=self))
|
||||
@@ -642,6 +710,7 @@ class Quota(LoggedModel):
|
||||
|
||||
now_dt = now_dt or now()
|
||||
return CartPosition.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(expires__gte=now_dt) &
|
||||
~Q(
|
||||
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
|
||||
@@ -655,14 +724,14 @@ class Quota(LoggedModel):
|
||||
|
||||
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING,
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
def count_paid_orders(self):
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PAID
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -394,6 +394,8 @@ class AbstractPosition(models.Model):
|
||||
:type price: decimal.Decimal
|
||||
:param attendee_name: The attendee's name, if entered.
|
||||
:type attendee_name: str
|
||||
:param attendee_email: The attendee's email, if entered.
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
:type voucher: Voucher
|
||||
"""
|
||||
@@ -418,9 +420,17 @@ class AbstractPosition(models.Model):
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher', null=True, blank=True
|
||||
)
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -486,13 +496,19 @@ class OrderPosition(AbstractPosition):
|
||||
from . import Voucher
|
||||
|
||||
ops = []
|
||||
for i, cartpos in enumerate(cp):
|
||||
cp_mapping = {}
|
||||
# The sorting key ensures that all addons come directly after the position they refer to
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
if f.name == 'addon_to':
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
cp_mapping[cartpos.pk] = op
|
||||
for answ in cartpos.answers.all():
|
||||
answ.orderposition = op
|
||||
answ.cartposition = None
|
||||
|
||||
@@ -3,17 +3,16 @@ import string
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import OrganizerSlugBlacklistValidator
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
from .settings import OrganizerSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(cache_namespace='organizer')
|
||||
class Organizer(LoggedModel):
|
||||
"""
|
||||
This model represents an entity organizing events, e.g. a company, institution,
|
||||
@@ -43,8 +42,6 @@ class Organizer(LoggedModel):
|
||||
],
|
||||
verbose_name=_("Short form"),
|
||||
)
|
||||
permitted = models.ManyToManyField(User, through='OrganizerPermission',
|
||||
related_name="organizers")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organizer")
|
||||
@@ -59,14 +56,6 @@ class Organizer(LoggedModel):
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this organizer's settings
|
||||
"""
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
return SettingsProxy(self, type=OrganizerSetting, parent=GlobalSettingsObject())
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
@@ -83,39 +72,131 @@ def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
|
||||
class OrganizerPermission(models.Model):
|
||||
class Team(LoggedModel):
|
||||
"""
|
||||
The relation between an Organizer and a User who has permissions to
|
||||
access an organizer profile.
|
||||
A team is a collection of people given certain access rights to one or more events of an organizer.
|
||||
|
||||
:param organizer: The organizer this relation refers to
|
||||
:param name: The name of this team
|
||||
:type name: str
|
||||
:param organizer: The organizer this team belongs to
|
||||
:type organizer: Organizer
|
||||
:param user: The user this set of permissions is valid for
|
||||
:type user: User
|
||||
:param can_create_events: Whether or not this user can create new events with this
|
||||
organizer account.
|
||||
:param members: A set of users who belong to this team
|
||||
:param all_events: Whether this team has access to all events of this organizer
|
||||
:type all_events: bool
|
||||
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``.
|
||||
:param can_create_events: Whether or not the members can create new events with this organizer account.
|
||||
:type can_create_events: bool
|
||||
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
|
||||
:type can_change_teams: bool
|
||||
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
|
||||
:type can_change_organizer_settings: bool
|
||||
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
|
||||
:type can_change_event_settings: bool
|
||||
:param can_change_items: If ``True``, the members can change and add items and related objects for the associated events.
|
||||
:type can_change_items: bool
|
||||
:param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events.
|
||||
:type can_view_orders: bool
|
||||
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
|
||||
:type can_change_orders: bool
|
||||
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
|
||||
:type can_view_vouchers: bool
|
||||
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
|
||||
:type can_change_vouchers: bool
|
||||
"""
|
||||
organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=190, verbose_name=_("Team name"))
|
||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||
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)
|
||||
|
||||
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True)
|
||||
invite_email = models.EmailField(null=True, blank=True)
|
||||
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
|
||||
can_create_events = models.BooleanField(
|
||||
default=True,
|
||||
default=False,
|
||||
verbose_name=_("Can create events"),
|
||||
)
|
||||
can_change_permissions = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Can change permissions"),
|
||||
can_change_teams = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change teams and permissions"),
|
||||
)
|
||||
can_change_organizer_settings = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change organizer settings")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organizer permission")
|
||||
verbose_name_plural = _("Organizer permissions")
|
||||
can_change_event_settings = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change event settings")
|
||||
)
|
||||
can_change_items = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change product settings")
|
||||
)
|
||||
can_view_orders = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can view orders")
|
||||
)
|
||||
can_change_orders = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change orders")
|
||||
)
|
||||
can_view_vouchers = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can view vouchers")
|
||||
)
|
||||
can_change_vouchers = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change vouchers")
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _("%(name)s on %(object)s") % {
|
||||
'name': str(self.user),
|
||||
'name': str(self.name),
|
||||
'object': str(self.organizer),
|
||||
}
|
||||
|
||||
def permission_set(self) -> set:
|
||||
attribs = dir(self)
|
||||
return {
|
||||
a for a in attribs if a.startswith('can_') and self.has_permission(a)
|
||||
}
|
||||
|
||||
@property
|
||||
def can_change_settings(self): # Legacy compatiblilty
|
||||
return self.can_change_event_settings
|
||||
|
||||
def has_permission(self, perm_name):
|
||||
try:
|
||||
return getattr(self, perm_name)
|
||||
except AttributeError:
|
||||
raise ValueError('Invalid required permission: %s' % perm_name)
|
||||
|
||||
def permission_for_event(self, event):
|
||||
if self.all_events:
|
||||
return event.organizer_id == self.organizer_id
|
||||
else:
|
||||
return self.limit_events.filter(pk=event.pk).exists()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Team")
|
||||
verbose_name_plural = _("Teams")
|
||||
|
||||
|
||||
class TeamInvite(models.Model):
|
||||
"""
|
||||
A TeamInvite represents someone who has been invited to a team but hasn't accept the invitation
|
||||
yet.
|
||||
|
||||
:param team: The team the person is invited to
|
||||
:type team: Team
|
||||
:param email: The email the invite has been sent to
|
||||
:type email: str
|
||||
:param token: The secret required to redeem the invite
|
||||
:type token: str
|
||||
"""
|
||||
team = models.ForeignKey(Team, related_name="invites", on_delete=models.CASCADE)
|
||||
email = models.EmailField(null=True, blank=True)
|
||||
token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return _("Invite to team '{team}' for '{email}'").format(
|
||||
team=str(self.team), email=self.email
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class GlobalSetting(models.Model):
|
||||
"""
|
||||
A global setting is a key-value setting which can be set for a
|
||||
pretix instance. It will be inherited by all events and organizers.
|
||||
It is filled via the register_global_settings signal.
|
||||
"""
|
||||
key = models.CharField(max_length=255, primary_key=True)
|
||||
value = models.TextField()
|
||||
|
||||
def __init__(self, *args, object=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizerSetting(models.Model):
|
||||
"""
|
||||
An organizer setting is a key-value setting which can be set for an
|
||||
organizer. It will be inherited by the events of this organizer
|
||||
"""
|
||||
object = models.ForeignKey('Organizer', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
|
||||
|
||||
class EventSetting(models.Model):
|
||||
"""
|
||||
An event setting is a key-value setting which can be set for a
|
||||
specific event
|
||||
"""
|
||||
object = models.ForeignKey('Event', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
@@ -150,9 +150,9 @@ class BasePaymentProvider:
|
||||
forms.BooleanField(
|
||||
label=_('Calculate the fee from the total value including the fee.'),
|
||||
help_text=_('We recommend you to enable this if you want your users to pay the payment fees of your '
|
||||
'payment provider. <a href="/control/help/payment/fee_reverse" target="_blank">Click here '
|
||||
'payment provider. <a href="{docs_url}" target="_blank">Click here '
|
||||
'for detailled information on what this does.</a> Don\'t forget to set the correct fees '
|
||||
'above!'),
|
||||
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
|
||||
required=False
|
||||
)),
|
||||
('_invoice_text',
|
||||
@@ -266,7 +266,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> "bool|str":
|
||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
|
||||
"""
|
||||
Will be called after the user selects this provider as his payment method.
|
||||
If you provided a form to the user to enter payment data, this method should
|
||||
@@ -394,14 +394,14 @@ class BasePaymentProvider:
|
||||
"""
|
||||
return False
|
||||
|
||||
def retry_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
|
||||
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
||||
"""
|
||||
Deprecated, use order_prepare instead
|
||||
"""
|
||||
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
|
||||
return self.order_prepare(request, order)
|
||||
|
||||
def order_prepare(self, request: HttpRequest, order: Order) -> "bool|str":
|
||||
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
||||
"""
|
||||
Will be called if the user retries to pay an unpaid order (after the user filled in
|
||||
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
|
||||
@@ -409,6 +409,10 @@ class BasePaymentProvider:
|
||||
|
||||
It should return and report errors the same way as :py:meth:`checkout_prepare`, but
|
||||
receives an ``Order`` object instead of a cart object.
|
||||
|
||||
Note: The ``Order`` object given to this method might be different from the version
|
||||
stored in the database as it's total will already contain the payment fee for the
|
||||
new payment method.
|
||||
"""
|
||||
form = self.payment_form(request)
|
||||
if form.is_valid():
|
||||
@@ -458,7 +462,7 @@ class BasePaymentProvider:
|
||||
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
|
||||
'please transfer the money back manually.')
|
||||
|
||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
|
||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
||||
"""
|
||||
Will be called if the event administrator confirms the refund.
|
||||
|
||||
@@ -523,7 +527,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
def order_control_refund_render(self, order: Order) -> str:
|
||||
return ''
|
||||
|
||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str":
|
||||
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
|
||||
"""
|
||||
Will be called if the event administrator confirms the refund.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections import Counter, namedtuple
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
@@ -27,6 +27,7 @@ error_messages = {
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
@@ -34,6 +35,9 @@ error_messages = {
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
@@ -45,11 +49,18 @@ error_messages = {
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
}
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas'))
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
@@ -74,7 +85,7 @@ class CartManager:
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
)
|
||||
).select_related('item')
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
@@ -97,9 +108,15 @@ class CartManager:
|
||||
|
||||
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
|
||||
self._items_cache.update(
|
||||
{i.pk: i for i in self.event.items.prefetch_related('quotas').filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)}
|
||||
{
|
||||
i.pk: i
|
||||
for i
|
||||
in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
@@ -111,9 +128,10 @@ class CartManager:
|
||||
)
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
@@ -133,7 +151,10 @@ class CartManager:
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.max_per_order:
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
raise CartError(error_messages['addon_only'])
|
||||
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations
|
||||
@@ -143,13 +164,21 @@ class CartManager:
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
if new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
if op.item.max_per_order and new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
if op.item.min_per_order and new_total < op.item.min_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
'min': op.item.min_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
@@ -236,7 +265,8 @@ class CartManager:
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'))
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -245,29 +275,144 @@ class CartManager:
|
||||
self._voucher_use_diff += voucher_use_diff
|
||||
self._operations += operations
|
||||
|
||||
def remove_items(self, items: List[dict]):
|
||||
def remove_item(self, pos_id: int):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for i in items:
|
||||
cw = Q(cart_id=self.cart_id) & Q(item_id=i['item']) & Q(event=self.event)
|
||||
if i['variation']:
|
||||
cw &= Q(variation_id=i['variation'])
|
||||
else:
|
||||
cw &= Q(variation__isnull=True)
|
||||
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
|
||||
# prefer the most expensive ones.
|
||||
cnt = i['count']
|
||||
if i['price']:
|
||||
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
|
||||
for cp in correctprice:
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
cnt -= len(correctprice)
|
||||
if cnt > 0:
|
||||
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
try:
|
||||
cp = self.positions.get(pk=pos_id)
|
||||
except CartPosition.DoesNotExist:
|
||||
raise CartError(error_messages['unknown_position'])
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def clear(self):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for cp in self.positions.all():
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def set_addons(self, addons):
|
||||
self._update_items_cache(
|
||||
[a['item'] for a in addons],
|
||||
[a['variation'] for a in addons],
|
||||
)
|
||||
|
||||
# Prepare various containers to hold data later
|
||||
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
|
||||
input_addons = defaultdict(set) # CartPos -> add-ons according to input
|
||||
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
|
||||
cpcache = {} # CartPos.pk -> CartPos
|
||||
quota_diff = Counter() # Quota -> Number of usages
|
||||
operations = []
|
||||
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
|
||||
toplevel_cp = self.positions.filter(
|
||||
addon_to__isnull=True
|
||||
).prefetch_related(
|
||||
'addons', 'item__addons', 'item__addons__addon_category'
|
||||
).select_related('item', 'variation')
|
||||
|
||||
# Prefill some of the cache containers
|
||||
for cp in toplevel_cp:
|
||||
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
|
||||
cpcache[cp.pk] = cp
|
||||
current_addons[cp] = {
|
||||
(a.item_id, a.variation_id): a
|
||||
for a in cp.addons.all()
|
||||
}
|
||||
|
||||
# Create operations, perform various checks
|
||||
for a in addons:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
# Only attach addons to things that are actually in this user's cart
|
||||
if a['addon_to'] not in cpcache:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
cp = cpcache[a['addon_to']]
|
||||
item = self._items_cache[a['item']]
|
||||
variation = self._variations_cache[a['variation']] if a['variation'] is not None else None
|
||||
|
||||
if item.category_id not in available_categories[cp.pk]:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
# Every item can be attached to very CartPosition at most once
|
||||
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
|
||||
raise CartError(error_messages['addon_duplicate_item'])
|
||||
|
||||
input_addons[cp.id].add((a['item'], a['variation']))
|
||||
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
|
||||
|
||||
if (a['item'], a['variation']) not in current_addons[cp]:
|
||||
# This add-on is new, add it to the cart
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += 1
|
||||
|
||||
price = self._get_price(item, variation, None, None)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
|
||||
# Check constraints on the add-on combinations
|
||||
for cp in toplevel_cp:
|
||||
item = cp.item
|
||||
for iao in item.addons.all():
|
||||
selected = selected_addons[cp.id, iao.addon_category_id]
|
||||
if len(selected) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif len(selected) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in current_addons.items():
|
||||
for k, v in al.items():
|
||||
if k not in input_addons[cp.id]:
|
||||
if v.expires > self.now_dt:
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
for quota in quotas:
|
||||
quota_diff[quota] -= 1
|
||||
|
||||
op = self.RemoveOperation(position=v)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff += quota_diff
|
||||
self._operations += operations
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = {}
|
||||
@@ -298,12 +443,47 @@ class CartManager:
|
||||
|
||||
return vouchers_ok
|
||||
|
||||
def _check_min_per_product(self):
|
||||
per_product = Counter()
|
||||
min_per_product = {}
|
||||
for p in self.positions:
|
||||
per_product[p.item_id] += 1
|
||||
min_per_product[p.item.pk] = p.item.min_per_order
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.AddOperation):
|
||||
per_product[op.item.pk] += op.count
|
||||
min_per_product[op.item.pk] = op.item.min_per_order
|
||||
elif isinstance(op, self.RemoveOperation):
|
||||
per_product[op.position.item_id] -= 1
|
||||
min_per_product[op.position.item.pk] = op.position.item.min_per_order
|
||||
|
||||
err = None
|
||||
for itemid, num in per_product.items():
|
||||
min_p = min_per_product[itemid]
|
||||
if min_p and num < min_p:
|
||||
self._operations = [o for o in self._operations if not (
|
||||
isinstance(o, self.AddOperation) and o.item.pk == itemid
|
||||
)]
|
||||
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
||||
for p in self.positions:
|
||||
if p.item_id == itemid and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
'min': min_p,
|
||||
'product': p.item.name
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
err = err or self._check_min_per_product()
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
|
||||
for op in self._operations:
|
||||
@@ -342,7 +522,8 @@ class CartManager:
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price, expires=self._expiry,
|
||||
cart_id=self.cart_id, voucher=op.voucher
|
||||
cart_id=self.cart_id, voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
@@ -377,7 +558,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher)
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param session: Session ID of a guest
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:raises CartError: On any error that occured
|
||||
@@ -396,11 +577,11 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number)
|
||||
:param position: A cart position ID
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
@@ -408,7 +589,48 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.remove_item(position)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.clear()
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.set_addons(addons)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -21,3 +21,4 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
|
||||
file.filename, file.type, data = ex.render(form_data)
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
return file.pk
|
||||
@@ -72,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
if p.addon_to_id:
|
||||
desc = " + " + desc
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
@@ -200,6 +202,14 @@ def _invoice_generate_german(invoice, f):
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if invoice.is_cancellation:
|
||||
@@ -243,20 +253,6 @@ def _invoice_generate_german(invoice, f):
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(165 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if invoice.event.settings.invoice_logo_image:
|
||||
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
canvas.drawImage(ImageReader(logo_file),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import bleach
|
||||
import cssutils
|
||||
@@ -114,6 +114,16 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
subject = "[%s] %s" % (prefix, subject)
|
||||
|
||||
body_plain += "\r\n\r\n-- \r\n"
|
||||
|
||||
signature = str(event.settings.get('mail_text_signature'))
|
||||
if signature:
|
||||
signature = signature.format(event=event.name)
|
||||
signature_md = signature.replace('\n', '<br>\n')
|
||||
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
|
||||
htmlctx['signature'] = signature_md
|
||||
body_plain += signature
|
||||
body_plain += "\r\n\r\n-- \r\n"
|
||||
|
||||
body_plain += _(
|
||||
"You are receiving this email because you placed an order for {event}."
|
||||
).format(event=event.name)
|
||||
@@ -138,7 +148,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
|
||||
@app.task
|
||||
def mail_send_task(to: str, subject: str, body: str, html: str, sender: str,
|
||||
def mail_send_task(to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
|
||||
email.attach_alternative(inline_css(html), "text/html")
|
||||
|
||||
@@ -562,6 +562,7 @@ class OrderChangeManager:
|
||||
'new_item': op.item.pk,
|
||||
'new_variation': op.variation.pk if op.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.item = op.item
|
||||
@@ -574,18 +575,29 @@ class OrderChangeManager:
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for opa in op.position.addons.all():
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': opa.pk,
|
||||
'positionid': opa.positionid,
|
||||
'old_item': opa.item.pk,
|
||||
'old_variation': opa.variation.pk if opa.variation else None,
|
||||
'addon_to': opa.addon_to_id,
|
||||
'old_price': opa.price,
|
||||
})
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_item': op.position.item.pk,
|
||||
'old_variation': op.position.variation.pk if op.position.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': None,
|
||||
})
|
||||
op.position.delete()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ def assign_automatically(event_id: int, user_id: int=None):
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def process_waitinglist(sender, **kwargs):
|
||||
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer')
|
||||
qs = Event.objects.prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
|
||||
for e in qs:
|
||||
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto:
|
||||
assign_automatically.apply_async(args=(e.pk,))
|
||||
@@ -1,19 +1,14 @@
|
||||
import decimal
|
||||
import json
|
||||
from datetime import date, datetime, time
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import dateutil.parser
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import ugettext_noop
|
||||
|
||||
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pretix.base.models.settings import GlobalSetting
|
||||
from typing import Any
|
||||
|
||||
DEFAULTS = {
|
||||
'max_items_per_order': {
|
||||
@@ -32,6 +27,14 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'attendee_emails_asked': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'invoice_address_asked': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -156,6 +159,14 @@ DEFAULTS = {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'ticket_download_nonadm': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
@@ -180,6 +191,10 @@ DEFAULTS = {
|
||||
'default': settings.MAIL_FROM,
|
||||
'type': str
|
||||
},
|
||||
'mail_text_signature': {
|
||||
'type': LazyI18nString,
|
||||
'default': ""
|
||||
},
|
||||
'mail_text_resend_link': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
@@ -382,194 +397,27 @@ Your {event} team"""))
|
||||
}
|
||||
}
|
||||
|
||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||
|
||||
class SettingsProxy:
|
||||
"""
|
||||
This object allows convenient access to settings stored in the
|
||||
EventSettings/OrganizerSettings database model. It exposes all settings as
|
||||
properties and it will do all the nasty inheritance and defaults stuff for
|
||||
you.
|
||||
"""
|
||||
for k, v in DEFAULTS.items():
|
||||
settings_hierarkey.add_default(k, v['default'], v['type'])
|
||||
|
||||
def __init__(self, obj: Model, parent: Optional[Model]=None, type=None):
|
||||
self._obj = obj
|
||||
self._parent = parent
|
||||
self._cached_obj = None
|
||||
self._write_cached_obj = None
|
||||
self._type = type
|
||||
|
||||
def _cache(self) -> Dict[str, Any]:
|
||||
if self._cached_obj is None:
|
||||
self._cached_obj = cache.get_or_set(
|
||||
'settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk),
|
||||
lambda: {s.key: s.value for s in self._obj.setting_objects.all()},
|
||||
timeout=1800
|
||||
)
|
||||
return self._cached_obj
|
||||
def i18n_uns(v):
|
||||
try:
|
||||
return LazyI18nString(json.loads(v))
|
||||
except ValueError:
|
||||
return LazyI18nString(str(v))
|
||||
|
||||
def _write_cache(self) -> Dict[str, Any]:
|
||||
if self._write_cached_obj is None:
|
||||
self._write_cached_obj = {
|
||||
s.key: s for s in self._obj.setting_objects.all()
|
||||
}
|
||||
return self._write_cached_obj
|
||||
|
||||
def _flush(self) -> None:
|
||||
self._cached_obj = None
|
||||
self._write_cached_obj = None
|
||||
self._flush_external_cache()
|
||||
settings_hierarkey.add_type(LazyI18nString,
|
||||
serialize=lambda s: json.dumps(s.data),
|
||||
unserialize=i18n_uns)
|
||||
|
||||
def _flush_external_cache(self):
|
||||
cache.delete('settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk))
|
||||
|
||||
def freeze(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of all settings set for this object, including
|
||||
any default values of its parents or hardcoded in pretix.
|
||||
"""
|
||||
settings = {}
|
||||
for key, v in DEFAULTS.items():
|
||||
settings[key] = self._unserialize(v['default'], v['type'])
|
||||
if self._parent:
|
||||
settings.update(self._parent.settings.freeze())
|
||||
for key in self._cache():
|
||||
settings[key] = self.get(key)
|
||||
return settings
|
||||
|
||||
def _unserialize(self, value: str, as_type: type, binary_file=False) -> Any:
|
||||
if as_type is None and value is not None and value.startswith('file://'):
|
||||
as_type = File
|
||||
|
||||
if as_type is not None and isinstance(value, as_type):
|
||||
return value
|
||||
elif value is None:
|
||||
return None
|
||||
elif as_type == int or as_type == float or as_type == decimal.Decimal:
|
||||
return as_type(value)
|
||||
elif as_type == dict or as_type == list:
|
||||
return json.loads(value)
|
||||
elif as_type == bool or value in ('True', 'False'):
|
||||
return value == 'True'
|
||||
elif as_type == File:
|
||||
try:
|
||||
fi = default_storage.open(value[7:], 'rb' if binary_file else 'r')
|
||||
fi.url = default_storage.url(value[7:])
|
||||
return fi
|
||||
except OSError:
|
||||
return False
|
||||
elif as_type == datetime:
|
||||
return dateutil.parser.parse(value)
|
||||
elif as_type == date:
|
||||
return dateutil.parser.parse(value).date()
|
||||
elif as_type == time:
|
||||
return dateutil.parser.parse(value).time()
|
||||
elif as_type == LazyI18nString and not isinstance(value, LazyI18nString):
|
||||
try:
|
||||
return LazyI18nString(json.loads(value))
|
||||
except ValueError:
|
||||
return LazyI18nString(str(value))
|
||||
elif as_type is not None and issubclass(as_type, Model):
|
||||
return as_type.objects.get(pk=value)
|
||||
return value
|
||||
|
||||
def _serialize(self, value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, int) or isinstance(value, float) \
|
||||
or isinstance(value, bool) or isinstance(value, decimal.Decimal):
|
||||
return str(value)
|
||||
elif isinstance(value, list) or isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
elif isinstance(value, datetime) or isinstance(value, date) or isinstance(value, time):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, Model):
|
||||
return value.pk
|
||||
elif isinstance(value, LazyI18nString):
|
||||
return json.dumps(value.data)
|
||||
elif isinstance(value, File):
|
||||
return 'file://' + value.name
|
||||
|
||||
raise TypeError('Unable to serialize %s into a setting.' % str(type(value)))
|
||||
|
||||
def get(self, key: str, default=None, as_type: type=None, binary_file=False):
|
||||
"""
|
||||
Get a setting specified by key ``key``. Normally, settings are strings, but
|
||||
if you put non-strings into the settings object, you can request unserialization
|
||||
by specifying ``as_type``. If the key does not have a harcdoded type in the pretix source,
|
||||
omitting ``as_type`` always will get you a string.
|
||||
|
||||
If the setting with the specified name does not exist on this object, any parent object
|
||||
will be queried (e.g. the organizer of an event). If still no value is found, a default
|
||||
value hardcoded will be returned if one exists. If not, the value of the ``default`` argument
|
||||
will be returned instead.
|
||||
"""
|
||||
if as_type is None and key in DEFAULTS:
|
||||
as_type = DEFAULTS[key]['type']
|
||||
|
||||
if key in self._cache():
|
||||
value = self._cache()[key]
|
||||
else:
|
||||
value = None
|
||||
if self._parent:
|
||||
value = self._parent.settings.get(key, as_type=str)
|
||||
if value is None and key in DEFAULTS:
|
||||
value = DEFAULTS[key]['default']
|
||||
if value is None and default is not None:
|
||||
value = default
|
||||
|
||||
return self._unserialize(value, as_type, binary_file=binary_file)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.get(key)
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
if key.startswith('_'):
|
||||
return super().__getattr__(key)
|
||||
return self.get(key)
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
if key.startswith('_'):
|
||||
return super().__setattr__(key, value)
|
||||
self.set(key, value)
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self.set(key, value)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Stores a setting to the database of its object.
|
||||
"""
|
||||
wc = self._write_cache()
|
||||
if key in wc:
|
||||
s = wc[key]
|
||||
else:
|
||||
s = self._type(object=self._obj, key=key)
|
||||
s.value = self._serialize(value)
|
||||
s.save()
|
||||
self._cache()[key] = s.value
|
||||
wc[key] = s
|
||||
self._flush_external_cache()
|
||||
|
||||
def __delattr__(self, key: str) -> None:
|
||||
if key.startswith('_'):
|
||||
return super().__delattr__(key)
|
||||
self.delete(key)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
self.delete(key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""
|
||||
Deletes a setting from this object's storage.
|
||||
"""
|
||||
if key in self._write_cache():
|
||||
self._write_cache()[key].delete()
|
||||
del self._write_cache()[key]
|
||||
|
||||
if key in self._cache():
|
||||
del self._cache()[key]
|
||||
|
||||
self._flush_external_cache()
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
class GlobalSettingsObject(GlobalSettingsBase):
|
||||
slug = '_global'
|
||||
|
||||
|
||||
class SettingsSandbox:
|
||||
@@ -614,13 +462,3 @@ class SettingsSandbox:
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
self._event.settings.set(self._convert_key(key), value)
|
||||
|
||||
|
||||
class GlobalSettingsObject():
|
||||
settings_namespace = 'global'
|
||||
|
||||
def __init__(self):
|
||||
self.settings = SettingsProxy(self, type=GlobalSetting)
|
||||
self.setting_objects = GlobalSetting.objects
|
||||
self.slug = '_global'
|
||||
self.pk = '_global'
|
||||
@@ -1,3 +1,4 @@
|
||||
import warnings
|
||||
from typing import Any, Callable, List, Tuple
|
||||
|
||||
import django.dispatch
|
||||
@@ -49,7 +50,14 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
responses.append((receiver, response))
|
||||
return responses
|
||||
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
|
||||
|
||||
|
||||
class DeprecatedSignal(django.dispatch.Signal):
|
||||
|
||||
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
|
||||
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
|
||||
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
|
||||
|
||||
|
||||
event_live_issues = EventPluginSignal(
|
||||
@@ -153,6 +161,21 @@ to the user. The receivers are expected to return HTML code.
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
event_copy_data = EventPluginSignal(
|
||||
providing_args=["other"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out when a new event is created as a clone of an existing event, i.e.
|
||||
the settings from the older event are copied to the newer one. You can listen to this
|
||||
signal to copy data or configuration stored within your plugin's models as well.
|
||||
|
||||
You don't need to copy data inside the general settings storage which is cloned automatically,
|
||||
but you might need to modify that data.
|
||||
|
||||
The ``sender`` keyword argument will contain the event of the **new** event. The ``other``
|
||||
keyword argument will contain the event to **copy from**.
|
||||
"""
|
||||
|
||||
periodic_task = django.dispatch.Signal()
|
||||
"""
|
||||
This is a regular django signal (no pretix event signal) that we send out every
|
||||
|
||||
@@ -148,6 +148,18 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if signature %}
|
||||
<tr>
|
||||
<td class="gap"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="order containertd">
|
||||
<div class="content">
|
||||
{{ signature | safe }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td class="footer">
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,7 @@ ALLOWED_TAGS = [
|
||||
'acronym',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'em',
|
||||
'i',
|
||||
@@ -46,6 +47,7 @@ def rich_text(text: str, **kwargs):
|
||||
"""
|
||||
Processes markdown and cleans HTML in a text input.
|
||||
"""
|
||||
text = str(text)
|
||||
body_md = bleach.linkify(bleach.clean(
|
||||
markdown.markdown(text),
|
||||
tags=ALLOWED_TAGS,
|
||||
|
||||
@@ -51,10 +51,18 @@ class BaseTicketOutput:
|
||||
This method should generate a download file and return a tuple consisting of a
|
||||
filename, a file type and file content. The extension will be taken from the filename
|
||||
which is otherwise ignored.
|
||||
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
|
||||
@@ -72,6 +72,7 @@ class AsyncAction:
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_success_url(res.info),
|
||||
'success': True,
|
||||
'message': str(self.get_success_message(res.info))
|
||||
})
|
||||
else:
|
||||
@@ -80,6 +81,7 @@ class AsyncAction:
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'success': False,
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
return data
|
||||
@@ -103,6 +105,7 @@ class AsyncAction:
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
'ready': True,
|
||||
'success': True,
|
||||
'redirect': self.get_success_url(value),
|
||||
'message': str(self.get_success_message(value))
|
||||
})
|
||||
@@ -113,6 +116,7 @@ class AsyncAction:
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
'ready': True,
|
||||
'success': False,
|
||||
'redirect': self.get_error_url(),
|
||||
'message': str(self.get_error_message(exception))
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from .signals import html_head, nav_event, nav_topbar
|
||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
|
||||
@@ -38,13 +38,19 @@ def contextprocessor(request):
|
||||
_nav_event += response
|
||||
if request.event.settings.get('payment_term_weekdays'):
|
||||
_js_payment_weekdays_disabled = '[0,6]'
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
ctx['nav_event'] = _nav_event
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
|
||||
_nav_global = []
|
||||
if not hasattr(request, 'event'):
|
||||
for receiver, response in nav_global.send(request, request=request):
|
||||
_nav_global += response
|
||||
ctx['nav_global'] = sorted(_nav_global, key=lambda n: n['label'])
|
||||
|
||||
_nav_topbar = []
|
||||
for receiver, response in nav_topbar.send(request, request=request):
|
||||
_nav_topbar += response
|
||||
ctx['nav_topbar'] = _nav_topbar
|
||||
ctx['nav_topbar'] = sorted(_nav_topbar, key=lambda n: n['label'])
|
||||
|
||||
ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS')
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
|
||||
@@ -2,12 +2,13 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from pytz import common_timezones, timezone
|
||||
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.control.forms import ExtFileField
|
||||
|
||||
@@ -26,7 +27,7 @@ class EventWizardFoundationForm(forms.Form):
|
||||
self.fields['organizer'] = forms.ModelChoiceField(
|
||||
label=_("Organizer"),
|
||||
queryset=Organizer.objects.filter(
|
||||
id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True)
|
||||
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
|
||||
),
|
||||
widget=forms.RadioSelect,
|
||||
empty_label=None,
|
||||
@@ -61,9 +62,11 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_basics-date_from'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_basics-presale_start'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -109,6 +112,16 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
|
||||
class EventWizardCopyForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
def copy_from_queryset(user):
|
||||
return Event.objects.filter(
|
||||
Q(organizer_id__in=user.teams.filter(
|
||||
all_events=True, can_change_event_settings=True, can_change_items=True
|
||||
).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter(
|
||||
can_change_event_settings=True, can_change_items=True
|
||||
).values_list('limit_events__id', flat=True))
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('organizer')
|
||||
kwargs.pop('locales')
|
||||
@@ -116,11 +129,7 @@ class EventWizardCopyForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['copy_from_event'] = forms.ModelChoiceField(
|
||||
label=_("Copy configuration from"),
|
||||
queryset=Event.objects.filter(
|
||||
id__in=self.user.event_perms.filter(
|
||||
can_change_items=True, can_change_settings=True
|
||||
).values_list('event', flat=True)
|
||||
),
|
||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
||||
widget=forms.RadioSelect,
|
||||
empty_label=_('Do not copy'),
|
||||
required=False
|
||||
@@ -134,6 +143,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -144,6 +154,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'currency',
|
||||
'date_from',
|
||||
'date_to',
|
||||
'date_admission',
|
||||
'is_public',
|
||||
'presale_start',
|
||||
'presale_end',
|
||||
@@ -151,9 +162,11 @@ class EventUpdateForm(I18nModelForm):
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
|
||||
'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_presale_start'}),
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +195,7 @@ class EventSettingsForm(SettingsForm):
|
||||
presale_start_show_date = forms.BooleanField(
|
||||
label=_("Show start date"),
|
||||
help_text=_("Show the presale start date before presale has started."),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_presale_start'}),
|
||||
required=False
|
||||
)
|
||||
last_order_modification_date = forms.DateTimeField(
|
||||
@@ -221,28 +235,48 @@ class EventSettingsForm(SettingsForm):
|
||||
min_value=6,
|
||||
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
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
)
|
||||
waiting_list_auto = forms.BooleanField(
|
||||
label=_("Automatic waiting list assignments"),
|
||||
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
|
||||
"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
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
)
|
||||
attendee_names_asked = forms.BooleanField(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all tickets which include admission to the event."),
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
attendee_names_required = forms.BooleanField(
|
||||
label=_("Require attendee names"),
|
||||
help_text=_("Require customers to fill in the names of all attendees."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
|
||||
)
|
||||
attendee_emails_asked = forms.BooleanField(
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be send "
|
||||
"to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every admission ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders."),
|
||||
required=False
|
||||
)
|
||||
attendee_emails_required = forms.BooleanField(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
)
|
||||
max_items_per_order = forms.IntegerField(
|
||||
min_value=1,
|
||||
label=_("Maximum number of items per order")
|
||||
label=_("Maximum number of items per order"),
|
||||
help_text=_("Add-on products will not be counted.")
|
||||
)
|
||||
reservation_time = forms.IntegerField(
|
||||
min_value=0,
|
||||
@@ -274,6 +308,10 @@ class EventSettingsForm(SettingsForm):
|
||||
raise ValidationError({
|
||||
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
|
||||
})
|
||||
if data['attendee_emails_required'] and not data['attendee_emails_asked']:
|
||||
raise ValidationError({
|
||||
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
@@ -346,7 +384,7 @@ class ProviderForm(SettingsForm):
|
||||
if isinstance(v, I18nFormField):
|
||||
v._required = v.one_required
|
||||
v.one_required = False
|
||||
v.widget.enabled_langcodes = self.obj.settings.get('locales')
|
||||
v.widget.enabled_locales = self.locales
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -366,11 +404,13 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
)
|
||||
invoice_address_required = forms.BooleanField(
|
||||
label=_("Require invoice address"),
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
)
|
||||
invoice_address_vatid = forms.BooleanField(
|
||||
label=_("Ask for VAT ID"),
|
||||
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_numbers_consecutive = forms.BooleanField(
|
||||
@@ -437,42 +477,58 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Sender address"),
|
||||
help_text=_("Sender address for outgoing emails")
|
||||
)
|
||||
|
||||
mail_text_signature = I18nFormField(
|
||||
label=_("Signature"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will be attached to every email. Available placeholders: {event}"),
|
||||
validators=[PlaceholderValidator(['{event}'])]
|
||||
)
|
||||
|
||||
mail_text_order_placed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}, "
|
||||
"{invoice_name}, {invoice_company}")
|
||||
"{invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total}', '{currency}', '{date}', '{paymentinfo}',
|
||||
'{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_order_paid = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
|
||||
)
|
||||
mail_text_order_free = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_link = I18nFormField(
|
||||
label=_("Text (sent by admin)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_all_links = I18nFormField(
|
||||
label=_("Text (requested by user)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {orders}")
|
||||
help_text=_("Available placeholders: {event}, {orders}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{orders}'])]
|
||||
)
|
||||
mail_days_order_expire_warning = forms.IntegerField(
|
||||
label=_("Number of days"),
|
||||
@@ -485,13 +541,15 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{expire_date}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_waiting_list = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{product}', '{hours}', '{code}'])]
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
@@ -547,7 +605,8 @@ class DisplaySettingsForm(SettingsForm):
|
||||
validators=[
|
||||
RegexValidator(regex='^#[0-9a-fA-F]{6}$',
|
||||
message=_('Please enter the hexadecimal code of a color, e.g. #990000.'))
|
||||
]
|
||||
],
|
||||
widget=forms.TextInput(attrs={'class': 'colorpickerfield'})
|
||||
)
|
||||
logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
@@ -577,7 +636,18 @@ class TicketSettingsForm(SettingsForm):
|
||||
label=_("Download date"),
|
||||
help_text=_("Ticket download will be offered after this date."),
|
||||
required=True,
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'})
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-display-dependency': '#id_ticket_download'}),
|
||||
)
|
||||
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'}),
|
||||
)
|
||||
|
||||
def prepare_fields(self):
|
||||
@@ -586,6 +656,10 @@ class TicketSettingsForm(SettingsForm):
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
if isinstance(v, I18nFormField):
|
||||
v._required = v.one_required
|
||||
v.one_required = False
|
||||
v.widget.enabled_locales = self.locales
|
||||
|
||||
def clean(self):
|
||||
# required=True files should only be required if the feature is enabled
|
||||
|
||||
@@ -29,7 +29,7 @@ class GlobalSettingsForm(SettingsForm):
|
||||
))
|
||||
])
|
||||
responses = register_global_settings.send(self)
|
||||
for r, response in responses:
|
||||
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
for key, value in response.items():
|
||||
# We need to be this explicit, since OrderedDict.update does not retain ordering
|
||||
self.fields[key] = value
|
||||
|
||||
@@ -2,6 +2,7 @@ import copy
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max
|
||||
from django.forms import BooleanField, ModelMultipleChoiceField
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
@@ -11,6 +12,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn
|
||||
|
||||
|
||||
class CategoryForm(I18nModelForm):
|
||||
@@ -19,7 +21,8 @@ class CategoryForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'description'
|
||||
'description',
|
||||
'is_addon'
|
||||
]
|
||||
|
||||
|
||||
@@ -108,6 +111,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
self.fields['copy_from'] = forms.ModelChoiceField(
|
||||
label=_("Copy product information"),
|
||||
queryset=self.event.items.all(),
|
||||
@@ -118,7 +122,6 @@ class ItemCreateForm(I18nModelForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.cleaned_data.get('copy_from'):
|
||||
self.instance.category = self.cleaned_data['copy_from'].category
|
||||
self.instance.description = self.cleaned_data['copy_from'].description
|
||||
self.instance.active = self.cleaned_data['copy_from'].active
|
||||
self.instance.available_from = self.cleaned_data['copy_from'].available_from
|
||||
@@ -126,6 +129,9 @@ class ItemCreateForm(I18nModelForm):
|
||||
self.instance.require_voucher = self.cleaned_data['copy_from'].require_voucher
|
||||
self.instance.hide_without_voucher = self.cleaned_data['copy_from'].hide_without_voucher
|
||||
self.instance.allow_cancel = self.cleaned_data['copy_from'].allow_cancel
|
||||
self.instance.min_per_order = self.cleaned_data['copy_from'].min_per_order
|
||||
self.instance.max_per_order = self.cleaned_data['copy_from'].max_per_order
|
||||
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
@@ -150,6 +156,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'category',
|
||||
'admission',
|
||||
'default_price',
|
||||
'tax_rate',
|
||||
@@ -180,7 +187,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'require_voucher',
|
||||
'hide_without_voucher',
|
||||
'allow_cancel',
|
||||
'max_per_order'
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
]
|
||||
widgets = {
|
||||
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -218,4 +226,64 @@ class ItemVariationForm(I18nModelForm):
|
||||
'value',
|
||||
'active',
|
||||
'default_price',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class ItemAddOnsFormSet(I18nFormSet):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.get('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
categories = set()
|
||||
for i in range(0, self.total_form_count()):
|
||||
form = self.forms[i]
|
||||
if self.can_delete:
|
||||
if self._should_delete_form(form):
|
||||
# This form is going to be deleted so any of its errors
|
||||
# should not cause the entire formset to be invalid.
|
||||
continue
|
||||
|
||||
if form.cleaned_data['addon_category'] in categories:
|
||||
raise ValidationError(_('You added the same add-on category twice'))
|
||||
|
||||
categories.add(form.cleaned_data['addon_category'])
|
||||
|
||||
@property
|
||||
def empty_form(self):
|
||||
self.is_valid()
|
||||
form = self.form(
|
||||
auto_id=self.auto_id,
|
||||
prefix=self.add_prefix('__prefix__'),
|
||||
empty_permitted=True,
|
||||
locales=self.locales,
|
||||
event=self.event
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
|
||||
|
||||
class ItemAddOnForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['addon_category'].queryset = self.event.categories.all()
|
||||
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'addon_category',
|
||||
'min_count',
|
||||
'max_count',
|
||||
]
|
||||
help_texts = {
|
||||
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
|
||||
'available add-ons are sold out.')
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
@@ -118,3 +119,16 @@ class OrderContactForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['email']
|
||||
|
||||
|
||||
class OrderLocaleForm(forms.ModelForm):
|
||||
locale = forms.ChoiceField()
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['locale']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'].choices = [(a, locale_names[a]) for a in self.instance.event.settings.locales]
|
||||
@@ -1,8 +1,13 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.models import Organizer, Team
|
||||
from pretix.control.forms import ExtFileField
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
|
||||
|
||||
class OrganizerForm(I18nModelForm):
|
||||
@@ -25,9 +30,99 @@ class OrganizerForm(I18nModelForm):
|
||||
|
||||
|
||||
class OrganizerUpdateForm(OrganizerForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.domain = kwargs.pop('domain', False)
|
||||
kwargs.setdefault('initial', {})
|
||||
self.instance = kwargs['instance']
|
||||
if self.domain and self.instance:
|
||||
initial_domain = self.instance.domains.first()
|
||||
if initial_domain:
|
||||
kwargs['initial'].setdefault('domain', initial_domain.domainname)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
if self.domain:
|
||||
self.fields['domain'] = forms.CharField(
|
||||
max_length=255,
|
||||
label=_('Custom domain'),
|
||||
required=False,
|
||||
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
||||
)
|
||||
|
||||
def clean_slug(self):
|
||||
return self.instance.slug
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit)
|
||||
|
||||
if self.domain:
|
||||
current_domain = instance.domains.first()
|
||||
if self.cleaned_data['domain']:
|
||||
if current_domain and current_domain.domainname != self.cleaned_data['domain']:
|
||||
current_domain.delete()
|
||||
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
|
||||
elif not current_domain:
|
||||
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
|
||||
elif current_domain:
|
||||
current_domain.delete()
|
||||
instance.get_cache().clear()
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
class TeamForm(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 = Team
|
||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||
'can_change_teams', 'can_change_organizer_settings',
|
||||
'can_change_event_settings', 'can_change_items',
|
||||
'can_view_orders', 'can_change_orders',
|
||||
'can_view_vouchers', 'can_change_vouchers']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
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
|
||||
).exists():
|
||||
raise ValidationError(_('The changes could not be saved because there would be no remaining team with '
|
||||
'the permission to change teams and permissions.'))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class OrganizerSettingsForm(SettingsForm):
|
||||
|
||||
locales = forms.MultipleChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
label=_("Use languages"),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text=_('Choose all languages that your organizer homepage should be available in.')
|
||||
)
|
||||
|
||||
organizer_homepage_text = I18nFormField(
|
||||
label=_('Homepage text'),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_('This will be displayed on the organizer homepage.')
|
||||
)
|
||||
|
||||
organizer_logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a logo image, we will by default not show your organization name '
|
||||
'in the page header. We will show your logo with a maximal height of 120 pixels.')
|
||||
)
|
||||
@@ -76,6 +76,9 @@ class VoucherForm(I18nModelForm):
|
||||
else:
|
||||
self.instance.variation = None
|
||||
self.instance.quota = None
|
||||
|
||||
if self.instance.item.category and self.instance.item.category.is_addon:
|
||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||
else:
|
||||
self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event)
|
||||
self.instance.item = None
|
||||
|
||||
@@ -95,6 +95,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
|
||||
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
|
||||
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
|
||||
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
|
||||
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
|
||||
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
|
||||
'pretix.event.quota.added': _('The quota has been added.'),
|
||||
'pretix.event.quota.deleted': _('The quota has been deleted.'),
|
||||
'pretix.event.quota.changed': _('The quota has been modified.'),
|
||||
@@ -118,7 +121,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
|
||||
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
|
||||
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'),
|
||||
'pretix.team.created': _('The team has been created.'),
|
||||
'pretix.team.changed': _('The team settings have been modified.'),
|
||||
'pretix.team.deleted': _('The team has been deleted.'),
|
||||
}
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
@@ -146,6 +152,23 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
if logentry.action_type.startswith('pretix.event.tickets.provider.'):
|
||||
return _('The settings of a ticket output provider have been changed.')
|
||||
|
||||
if logentry.action_type == 'pretix.team.member.added':
|
||||
return _('{user} has been added to the team.').format(user=data.get('email'))
|
||||
|
||||
if logentry.action_type == 'pretix.team.member.removed':
|
||||
return _('{user} has been removed from the team.').format(user=data.get('email'))
|
||||
|
||||
if logentry.action_type == 'pretix.team.member.joined':
|
||||
return _('{user} has joined the team using the invite sent to {email}.').format(
|
||||
user=data.get('email'), email=data.get('invite_email')
|
||||
)
|
||||
|
||||
if logentry.action_type == 'pretix.team.invite.created':
|
||||
return _('{user} has been invited to the team.').format(user=data.get('email'))
|
||||
|
||||
if logentry.action_type == 'pretix.team.invite.deleted':
|
||||
return _('The invite for {user} has been revoked.').format(user=data.get('email'))
|
||||
|
||||
if logentry.action_type == 'pretix.user.settings.changed':
|
||||
text = str(_('Your account settings have been changed.'))
|
||||
if 'email' in data:
|
||||
|
||||
@@ -9,9 +9,7 @@ from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, EventPermission, Organizer, OrganizerPermission,
|
||||
)
|
||||
from pretix.base.models import Event, Organizer
|
||||
|
||||
|
||||
class PermissionMiddleware(MiddlewareMixin):
|
||||
@@ -61,53 +59,23 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
return redirect_to_login(
|
||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
|
||||
events = Event.objects.all() if request.user.is_superuser else request.user.events
|
||||
request.user.events_cache = events.order_by(
|
||||
"organizer", "date_from").prefetch_related("organizer")
|
||||
events = request.user.get_events_with_any_permission()
|
||||
request.user.events_cache = events.order_by("organizer", "date_from").prefetch_related("organizer")
|
||||
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
||||
try:
|
||||
if request.user.is_superuser:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer')[0]
|
||||
request.eventperm = EventPermission(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
else:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
permitted__id__exact=request.user.id,
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer')[0]
|
||||
request.eventperm = EventPermission.objects.get(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
request.organizer = request.event.organizer
|
||||
except IndexError:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer').first()
|
||||
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event):
|
||||
raise Http404(_("The selected event was not found or you "
|
||||
"have no permission to administrate it."))
|
||||
request.organizer = request.event.organizer
|
||||
request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event)
|
||||
elif 'organizer' in url.kwargs:
|
||||
try:
|
||||
if request.user.is_superuser:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
)[0]
|
||||
request.orgaperm = OrganizerPermission(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
else:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
permitted__id__exact=request.user.id,
|
||||
)[0]
|
||||
request.orgaperm = OrganizerPermission.objects.get(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
except IndexError:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
).first()
|
||||
if not request.organizer or not request.user.has_organizer_permission(request.organizer):
|
||||
raise Http404(_("The selected organizer was not found or you "
|
||||
"have no permission to administrate it."))
|
||||
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)
|
||||
@@ -1,37 +1,29 @@
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.models import EventPermission, OrganizerPermission
|
||||
|
||||
|
||||
def event_permission_required(permission):
|
||||
"""
|
||||
This view decorator rejects all requests with a 403 response which are not from
|
||||
users having the given permission for the event the request is associated with.
|
||||
"""
|
||||
if permission == 'can_change_settings':
|
||||
# Legacy support
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def decorator(function):
|
||||
def wrapper(request, *args, **kw):
|
||||
if not request.user.is_authenticated: # NOQA
|
||||
# just a double check, should not ever happen
|
||||
raise PermissionDenied()
|
||||
if request.user.is_superuser:
|
||||
|
||||
allowed = (
|
||||
request.user.is_superuser
|
||||
or request.user.has_event_permission(request.organizer, request.event, permission)
|
||||
)
|
||||
if allowed:
|
||||
return function(request, *args, **kw)
|
||||
try:
|
||||
perm = EventPermission.objects.get(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
except EventPermission.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
allowed = not permission
|
||||
try:
|
||||
if permission:
|
||||
allowed = getattr(perm, permission)
|
||||
except AttributeError:
|
||||
pass
|
||||
if allowed or request.user.is_superuser:
|
||||
return function(request, *args, **kw)
|
||||
|
||||
raise PermissionDenied(_('You do not have permission to view this content.'))
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -55,29 +47,23 @@ def organizer_permission_required(permission):
|
||||
This view decorator rejects all requests with a 403 response which are not from
|
||||
users having the given permission for the event the request is associated with.
|
||||
"""
|
||||
if permission == 'can_change_settings':
|
||||
# Legacy support
|
||||
permission = 'can_change_organizer_settings'
|
||||
|
||||
def decorator(function):
|
||||
def wrapper(request, *args, **kw):
|
||||
if not request.user.is_authenticated: # NOQA
|
||||
# just a double check, should not ever happen
|
||||
raise PermissionDenied()
|
||||
if request.user.is_superuser:
|
||||
|
||||
allowed = (
|
||||
request.user.is_superuser
|
||||
or request.user.has_organizer_permission(request.organizer, permission)
|
||||
)
|
||||
if allowed:
|
||||
return function(request, *args, **kw)
|
||||
try:
|
||||
perm = OrganizerPermission.objects.get(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
except OrganizerPermission.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
allowed = not permission
|
||||
try:
|
||||
if permission:
|
||||
allowed = getattr(perm, permission)
|
||||
except AttributeError:
|
||||
pass
|
||||
if allowed or request.user.is_superuser:
|
||||
return function(request, *args, **kw)
|
||||
|
||||
raise PermissionDenied(_('You do not have permission to view this content.'))
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
from pretix.base.signals import EventPluginSignal
|
||||
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
|
||||
|
||||
restriction_formset = EventPluginSignal(
|
||||
providing_args=["item"]
|
||||
@@ -47,11 +47,31 @@ nav_topbar = Signal(
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the top navigation bar.
|
||||
You will get the request as a keyword argument ``return``.
|
||||
You will get the request as a keyword argument ``request``.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You can also return
|
||||
a fontawesome icon name with the key ``icon``, it will be respected depending
|
||||
on the type of navigation. If set, on desktops only the ``icon`` will be shown.
|
||||
The ``title`` property can be used to set the alternative text.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
|
||||
This is no ``EventPluginSignal``, so you do not get the event in the ``sender`` argument
|
||||
and you may get the signal regardless of whether your plugin is active.
|
||||
"""
|
||||
|
||||
nav_global = Signal(
|
||||
providing_args=["request"]
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the navigation bar when no event is
|
||||
selected. You will get the request as a keyword argument ``request``.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You can also return
|
||||
a fontawesome icon name with the key ``icon``, it will be respected depending
|
||||
on the type of navigation. You should also return an ``active`` key with a boolean
|
||||
set to ``True``, when this item should be marked as active.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
@@ -123,14 +143,29 @@ quota as argument in the ``quota`` keyword argument.
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
organizer_edit_tabs = Signal(
|
||||
organizer_edit_tabs = DeprecatedSignal(
|
||||
providing_args=['organizer', 'request']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tabs on the detail page of an organizer. Receivers
|
||||
should return a tuple with the first item being the tab title and the second item
|
||||
being the content as HTML. The receivers get the ``organizer`` and the ``request`` as
|
||||
keyword arguments.
|
||||
Deprecated signal, no longer works. We just keep the definition so old plugins don't
|
||||
break the installation.
|
||||
"""
|
||||
|
||||
This is a regular django signal (no pretix event signal).
|
||||
|
||||
nav_organizer = Signal(
|
||||
providing_args=['organizer', 'request']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tab links on the detail page of an organizer.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You should also return
|
||||
an ``active`` key with a boolean set to ``True``, when this item should be marked
|
||||
as active.
|
||||
|
||||
If your linked view should stay in the tab-like context of this page, we recommend
|
||||
that you use ``pretix.control.views.organizer.OrganizerDetailViewMixin`` for your view
|
||||
and your tempalte inherits from ``pretixcontrol/organizers/base.html``.
|
||||
|
||||
This is a regular django signal (no pretix event signal). Receivers will be passed
|
||||
the keyword arguments ``organizer`` and ``request``.
|
||||
"""
|
||||
@@ -32,8 +32,12 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "colorpicker/bootstrap-colorpicker.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fileupload/jquery.ui.widget.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{{ html_head|safe }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -64,7 +68,7 @@
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
|
||||
{{ request.event }} <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="{% url "control:events" %}">{% trans "Event overview" %}</a></li>
|
||||
<li><a href="{% url "control:events" %}">{% trans "Events overview" %}</a></li>
|
||||
{% regroup request.user.events_cache by organizer as event_list %}
|
||||
{% for g in event_list %}
|
||||
<li class="dropdown-header">{{ g.grouper }}</li>
|
||||
@@ -78,7 +82,7 @@
|
||||
<ul class="nav navbar-nav navbar-top-links navbar-right">
|
||||
{% for nav in nav_topbar %}
|
||||
<li {% if nav.children %}class="dropdown"{% endif %}>
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<span class="fa fa-{{ nav.icon }}"></span>
|
||||
@@ -113,7 +117,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{% url 'control:user.settings' %}">
|
||||
<a href="{% url 'control:user.settings' %}" title="{% trans "Account Settings" %}" >
|
||||
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -167,6 +171,32 @@
|
||||
{% trans "Organizers" %}
|
||||
</a>
|
||||
</li>
|
||||
{% for nav in nav_global %}
|
||||
<li>
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="has-children"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
|
||||
{% endif %}
|
||||
{{ nav.label }}
|
||||
</a>
|
||||
{% if nav.children %}
|
||||
<a href="#" class="arrow">
|
||||
<span class="fa arrow"></span>
|
||||
</a>
|
||||
<ul class="nav nav-second-level">
|
||||
{% for item in nav.children %}
|
||||
<li>
|
||||
<a href="{{ item.url }}"
|
||||
{% if item.active %}class="active"{% endif %}>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% block title %}{% trans "Check-ins" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Check-ins" %}</h1>
|
||||
<p>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<select name="status" class="form-control">
|
||||
<option value="">{% trans "All status" %}</option>
|
||||
<option value="1" {% if request.GET.status == "1" %}selected="selected"{% endif %}>{% trans "Checked in" %}</option>
|
||||
<option value="0" {% if request.GET.status == "0" %}selected="selected"{% endif %}>{% trans "Not checked in" %}</option>
|
||||
</select>
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
{% for item in items %}
|
||||
<option value="{{ item.id }}"
|
||||
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
|
||||
{{ item.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="user" class="form-control" placeholder="{% trans "Search user" %}" value="{{ request.GET.user }}">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</form>
|
||||
</p>
|
||||
{% if entries|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No check-in record was found.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Item" %} <a href="?{% url_replace request 'ordering' '-item'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'item'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Email" %} <a href="?{% url_replace request 'ordering' '-email'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'email'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Name" %} <a href="?{% url_replace request 'ordering' '-name'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'name'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Status" %} <a href="?{% url_replace request 'ordering' '-status'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'status'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Timestamp" %} <a href="?{% url_replace request 'ordering' '-timestamp'%}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'timestamp'%}"><i class="fa fa-caret-up"></i></a></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
{% with e.checkins.first as checkin %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}"
|
||||
>{{ e.order.code }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.item.name }}</td>
|
||||
<td>{{ e.order.email }}</td>
|
||||
<td>
|
||||
{% if e.addon_to %}
|
||||
{{ e.addon_to.attendee_name }}
|
||||
{% elif e.attendee_name %}
|
||||
{{ e.attendee_name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not checkin %}
|
||||
<span class="label label-danger">{% trans "Not checked in" %}</span>
|
||||
{% else %}
|
||||
<span class="label label-success">{% trans "Checked in" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if checkin %}
|
||||
{{ checkin.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,10 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
||||
|
||||
you have been invited to the team of an event that uses pretix for their
|
||||
you have been invited to a team on pretix, a platform to perform event
|
||||
ticket sales.
|
||||
|
||||
Event: {{ event }}
|
||||
Organizer: {{ organizer }}
|
||||
Team: {{ team }}
|
||||
|
||||
If you want to join that team, just click on the following link:
|
||||
{{ url }}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
||||
|
||||
you have been invited to the team of an event organizer that uses pretix
|
||||
for their ticket sales.
|
||||
|
||||
Organizer: {{ organizer }}
|
||||
|
||||
If you want to join that team, just click on the following link:
|
||||
{{ url }}
|
||||
|
||||
If you do not want to join, you can safely ignore or delete this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your pretix team
|
||||
{% endblocktrans %}
|
||||
@@ -10,7 +10,7 @@
|
||||
{% trans "Dashboard" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.eventperm.can_change_settings or request.eventperm.can_change_permissions %}
|
||||
{% if 'can_change_event_settings' in request.eventpermset or 'can_change_permissions' in request.eventpermset %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
<i class="fa fa-wrench fa-fw"></i>
|
||||
@@ -18,7 +18,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.eventperm.can_change_items %}
|
||||
{% if 'can_change_items' in request.eventpermset %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="has-children">
|
||||
@@ -55,7 +55,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.eventperm.can_view_orders %}
|
||||
{% if 'can_view_orders' in request.eventpermset %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.orders' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="has-children">
|
||||
@@ -90,10 +90,16 @@
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'control:event.orders.checkins' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if url_name == "event.orders.checkins" %}class="active"{% endif %}>
|
||||
{% trans "Check-ins" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.eventperm.can_view_vouchers %}
|
||||
{% if 'can_view_vouchers' in request.eventpermset %}
|
||||
<li>
|
||||
<a href="{% url 'control:event.vouchers' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if url_name == "event.vouchers" %}class="active"{% endif %}>
|
||||
|
||||
@@ -85,12 +85,15 @@
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-6 col-xs-12">
|
||||
{% if log.user %}
|
||||
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
|
||||
{% if log.user.is_superuser %}
|
||||
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
|
||||
data-toggle="tooltip" class="user-admin-icon"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ log.user.get_full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
{% trans "Customer actions" %}
|
||||
</option>
|
||||
{% for up in userlist %}
|
||||
<option value="{{ up.user_id }}" {% if request.GET.user == up.user_id %}selected="selected"{% endif %}>
|
||||
{{ up.user }}
|
||||
</option>
|
||||
{% if up.user__id %}
|
||||
<option value="{{ up.user__id }}" {% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
|
||||
{{ up.user__email }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
@@ -32,12 +34,15 @@
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-6 col-xs-12">
|
||||
{% if log.user %}
|
||||
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
|
||||
{% if log.user.is_superuser %}
|
||||
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
|
||||
data-toggle="tooltip" class="user-admin-icon"
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ log.user.get_full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -2,117 +2,39 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
||||
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail settings" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_from layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_text_signature layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_placed">
|
||||
<strong>{% trans "Placed order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_placed" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_placed layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_paid">
|
||||
<strong>{% trans "Paid order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_paid" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_paid layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_free">
|
||||
<strong>{% trans "Free order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_free" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_free layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#resend_link">
|
||||
<strong>{% trans "Resend link" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="resend_link" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_resend_link layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_text_resend_all_links layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_changed">
|
||||
<strong>{% trans "Order changed" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_changed" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_changed layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_expirew">
|
||||
<strong>{% trans "Payment reminder" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_expirew" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_days_order_expire_warning layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_text_order_expire_warning layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#waiting_list">
|
||||
<strong>{% trans "Waiting list notification" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="waiting_list" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_waiting_list layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed" %}
|
||||
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid" %}
|
||||
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free" %}
|
||||
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
|
||||
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
|
||||
|
||||
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
|
||||
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load mail_settings_preview %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#{{ pid }}">
|
||||
<strong>{% trans title %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="{{ pid }}" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% with exclude|split as exclusion %}
|
||||
{% with items|split as item_list %}
|
||||
{% for item in item_list %}
|
||||
{% if item in exclusion %}
|
||||
{% with form|getattr:item as field %}
|
||||
{% bootstrap_field field layout="horizontal" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div id="{{ item }}_panel" class="preview-panel form-group" for="{{ item }}">
|
||||
{% with form|getattr:item as field %}
|
||||
<label class="col-md-3 control-label">{{ field.label }}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="tab-content">
|
||||
<div id="{{ item }}_edit" class="tab-pane fade in active">
|
||||
{% bootstrap_field field show_label=False form_group_class="" %}
|
||||
</div>
|
||||
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
||||
{% for l in request.event.settings.locales %}
|
||||
<pre lang="{{ l }}" for="{{ item }}" class="mail-preview"></pre>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav nav-pills pull-right">
|
||||
<li role="presentation" class="active">
|
||||
<a data-toggle="pill" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a data-toggle="pill" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,88 +1,19 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Permissions" %}</legend>
|
||||
{% bootstrap_formset_errors formset %}
|
||||
{{ formset.management_form }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Change settings" %}</th>
|
||||
<th>{% trans "Change products" %}</th>
|
||||
<th>{% trans "View orders" %}</th>
|
||||
<th>{% trans "Change orders" %}</th>
|
||||
<th>{% trans "Change permissions" %}</th>
|
||||
<th>{% trans "View vouchers" %}</th>
|
||||
<th>{% trans "Change vouchers" %}</th>
|
||||
<th>{% trans "Delete" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.id }}
|
||||
{% if form.instance.user %}
|
||||
{{ form.instance.user }}
|
||||
{% else %}
|
||||
{{ form.instance.invite_email }}
|
||||
<span class="fa fa-envelope-o" data-toggle="tooltip"
|
||||
title="{% trans "invited, pending response" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ form.can_change_settings }}</td>
|
||||
<td>{{ form.can_change_items }}</td>
|
||||
<td>{{ form.can_view_orders }}</td>
|
||||
<td>{{ form.can_change_orders }}</td>
|
||||
<td>{{ form.can_change_permissions }}</td>
|
||||
<td>{{ form.can_view_vouchers }}</td>
|
||||
<td>{{ form.can_change_vouchers }}</td>
|
||||
<td>{{ form.DELETE }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<strong>{% trans "Adding a new user" %}</strong><br>
|
||||
{% blocktrans trimmed %}
|
||||
To add a new user, you can enter their email address here. If they already have a
|
||||
pretix account, they will immediately be added to the event. Otherwise, they will
|
||||
be sent an email with an invitation.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="row-fluid">
|
||||
<div class="col-sm-12">
|
||||
{% bootstrap_field add_form.user layout='inline' %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ add_form.can_change_settings }}</td>
|
||||
<td>{{ add_form.can_change_items }}</td>
|
||||
<td>{{ add_form.can_view_orders }}</td>
|
||||
<td>{{ add_form.can_change_orders }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
<td>{{ add_form.can_view_vouchers }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<div class="section-moved">
|
||||
<img src="{% static "pretixcontrol/img/moved.svg" %}" class="img-moved">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
Permission settings have moved and are now configured as part of an organizer account instead
|
||||
of every event on its own.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:organizer.teams" organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-link btn-lg">{% trans "Go to the organizer team settings" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -12,6 +12,7 @@
|
||||
{% bootstrap_field form.date_from layout="horizontal" %}
|
||||
{% bootstrap_field form.date_to layout="horizontal" %}
|
||||
{% bootstrap_field form.location layout="horizontal" %}
|
||||
{% bootstrap_field form.date_admission layout="horizontal" %}
|
||||
{% bootstrap_field form.currency layout="horizontal" %}
|
||||
{% bootstrap_field form.is_public layout="horizontal" %}
|
||||
</fieldset>
|
||||
@@ -41,6 +42,8 @@
|
||||
{% bootstrap_field sform.max_items_per_order layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_names_asked layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_emails_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<h1>{% trans "Settings" %}</h1>
|
||||
<ul class="nav nav-pills">
|
||||
{% if request.eventperm.can_change_settings %}
|
||||
{% if 'can_change_event_settings' in request.eventpermset %}
|
||||
<li {% if "event.settings" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.settings' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% trans "General" %}
|
||||
@@ -41,8 +41,6 @@
|
||||
{% trans "Invoicing" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.eventperm.can_change_permissions %}
|
||||
<li {% if "event.settings.permissions" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.settings.permissions' organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||
{% trans "Permissions" %}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.ticket_download layout="horizontal" %}
|
||||
{% bootstrap_field form.ticket_download_date layout="horizontal" %}
|
||||
{% bootstrap_field form.ticket_download_addons layout="horizontal" %}
|
||||
{% bootstrap_field form.ticket_download_nonadm layout="horizontal" %}
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default ticketoutput-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||