forked from CGM_Public/pretix_original
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a6a3544628 | ||
|
|
ca762083b6 | ||
|
|
550ab7de18 | ||
|
|
4919f8991c | ||
|
|
867a8132aa | ||
|
|
c661122bb6 | ||
|
|
80bd8d2039 | ||
|
|
7267496367 | ||
|
|
9dacea11dd | ||
|
|
91c48c50e5 | ||
|
|
67e5ecb931 | ||
|
|
887152a0e2 | ||
|
|
c1a76c4c18 | ||
|
|
8dacbe0fc6 | ||
|
|
a4ead5bd07 | ||
|
|
2f6e36c504 | ||
|
|
bcdb4fd000 | ||
|
|
99395c722d | ||
|
|
e28030576a | ||
|
|
455b0f2015 | ||
|
|
6da0125b7d | ||
|
|
48912bdf55 | ||
|
|
ba70ddfb76 | ||
|
|
f828fcdcab | ||
|
|
c1403207de | ||
|
|
4514bd7e53 | ||
|
|
f2378168c1 | ||
|
|
e0e3a72268 | ||
|
|
c932892dbd | ||
|
|
f03ad7c68f | ||
|
|
d3a26d8022 | ||
|
|
446698d52f | ||
|
|
69faab01b2 | ||
|
|
36d6b6f9ab | ||
|
|
ea70b5fa46 | ||
|
|
927e21e5d1 | ||
|
|
259c0cca69 | ||
|
|
11ce4c2078 | ||
|
|
76ec402fc5 | ||
|
|
df956816b4 | ||
|
|
5d431b3843 | ||
|
|
91ca4f2184 | ||
|
|
b00a0eccc6 | ||
|
|
d675ad18e0 | ||
|
|
031ed8f3cd | ||
|
|
aed78c2d69 | ||
|
|
af3e811f94 | ||
|
|
811c498080 | ||
|
|
e6d58b3b0d | ||
|
|
b7dc671028 | ||
|
|
8418eb2c6b | ||
|
|
5a882a0fae | ||
|
|
be1cbfeb91 | ||
|
|
96c61a073c | ||
|
|
64ef293ce2 | ||
|
|
55953d5b4e | ||
|
|
c63e69db5f | ||
|
|
f9646d9325 | ||
|
|
6bbdbddfaa | ||
|
|
177d46ab8d | ||
|
|
ecd90da554 | ||
|
|
2302dbade6 | ||
|
|
cbf735487f | ||
|
|
a10090b1fb | ||
|
|
babf76371e | ||
|
|
1baac6bb21 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,4 +21,6 @@ pretixeu/
|
||||
local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
|
||||
|
||||
|
||||
17
.travis.sh
17
.travis.sh
@@ -30,7 +30,7 @@ if [ "$1" == "tests" ]; then
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && coverage report
|
||||
py.test --rerun 5 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
@@ -39,3 +39,18 @@ if [ "$1" == "tests-cov" ]; then
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python setup.py develop
|
||||
make all compress
|
||||
|
||||
pushd ~
|
||||
git clone --depth 1 https://github.com/pretix/pretix-cartshare.git
|
||||
cd pretix-cartshare
|
||||
python setup.py develop
|
||||
make
|
||||
py.test --rerun 5 tests
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
@@ -32,6 +32,8 @@ matrix:
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.4
|
||||
env: JOB=style
|
||||
- python: 3.4
|
||||
env: JOB=plugins
|
||||
- python: 3.4
|
||||
env: JOB=tests-cov
|
||||
addons:
|
||||
|
||||
17
AUTHORS
17
AUTHORS
@@ -3,18 +3,31 @@ people who have submitted patches, reported bugs, added translations, helped
|
||||
answer newbie questions, improved the documentation, and generally made pretix
|
||||
an awesome project. Thank you all!
|
||||
|
||||
Adam K. Sumner <asumner101@gmail.com>
|
||||
Ahrdie <robert.deppe@me.com>
|
||||
Alexander Brock <Brock.Alexander@web.de>
|
||||
Ben Oswald
|
||||
Brandon Pineda
|
||||
Bolutife Lawrence
|
||||
Christian Franke <nobody@nowhere.ws>
|
||||
Christopher Dambamuromo <me@chridam.com>
|
||||
chotee <chotee@openended.eu>
|
||||
Cpt. Foo
|
||||
Daniel Rosenblüh
|
||||
Enrique Saez
|
||||
Flavia Bastos
|
||||
informancer <informancer@web.de>
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
Jakob Schnell <github@ezelo.de>
|
||||
Jan Felix Wiebe <git@jfwie.be>
|
||||
Jan Weiß
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
jlwt90
|
||||
Jonas Große Sundrup <cherti@letopolis.de>
|
||||
Kevin Nelson
|
||||
Leah Oswald
|
||||
Lukas Martini
|
||||
Nathan Mattes
|
||||
Nicole Klünder
|
||||
Marc-Pascal Clement
|
||||
Martin Gross <martin@pc-coholic.de>
|
||||
Raphael Michel <mail@raphaelmichel.de>
|
||||
Team MRMCD
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -155,6 +155,8 @@ Example::
|
||||
``admins``
|
||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||
|
||||
.. _`django-settings`:
|
||||
|
||||
Django settings
|
||||
---------------
|
||||
|
||||
@@ -179,6 +181,11 @@ Example::
|
||||
|
||||
.. WARNING:: Never set this to ``True`` in production!
|
||||
|
||||
``profile``
|
||||
Enable code profiling for a random subset of requests. Disabled by default, see
|
||||
:ref:`perf-monitoring` for details.
|
||||
|
||||
.. _`metrics-settings`:
|
||||
|
||||
Metrics
|
||||
-------
|
||||
|
||||
@@ -10,3 +10,4 @@ Contents:
|
||||
|
||||
installation/index
|
||||
config
|
||||
maintainance
|
||||
|
||||
@@ -222,6 +222,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
@@ -244,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;
|
||||
@@ -255,6 +258,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
|
||||
99
doc/admin/maintainance.rst
Normal file
99
doc/admin/maintainance.rst
Normal file
@@ -0,0 +1,99 @@
|
||||
.. highlight:: ini
|
||||
|
||||
.. _`maintainance`:
|
||||
|
||||
Backups and Monitoring
|
||||
======================
|
||||
|
||||
If you host your own pretix instance, you also need to care about the availability
|
||||
of your service and the safety of your data yourself. This page gives you some
|
||||
information that you might need to do so properly.
|
||||
|
||||
Backups
|
||||
-------
|
||||
|
||||
There are essentially two things which you should create backups of:
|
||||
|
||||
Database
|
||||
Your SQL database (MySQL or PostgreSQL). This is critical and you should **absolutely
|
||||
always create automatic backups of your database**. There are tons of tutorials on the
|
||||
internet on how to do this, and the exact process depends on the choice of your database.
|
||||
For MySQL, see ``mysqldump`` and for PostgreSQL, see the ``pg_dump`` tool. You probably
|
||||
want to create a cronjob that does the backups for you on a regular schedule.
|
||||
|
||||
Data directory
|
||||
The data directory of your pretix configuration might contain some things that you should
|
||||
back up. If you did not specify a secret in your config file, back up the ``.secret`` text
|
||||
file in the data directory. If you lose your secret, all currently active user sessions,
|
||||
password reset links and similar things will be rendered invalid. Also, you probably want
|
||||
to backup the ``media`` subdirectory of the data directory which contains all user-uploaded
|
||||
and generated files. This includes files you could in theory regenerate (ticket downloads)
|
||||
but also files that you might be legally required to keep (invoice PDFs) or files that you
|
||||
would need to re-upload (event logos, product pictures, etc.). It is up to you if you
|
||||
create regular backups of this data, but we strongly advise you to do so. You can create
|
||||
backups e.g. using ``rsync``. There is a lot of information on the internet on how to create
|
||||
backups of folders on a Linux machine.
|
||||
|
||||
There is no need to create backups of the redis database, if you use it. We only use it for
|
||||
non-critical, temporary or cached data.
|
||||
|
||||
Uptime monitoring
|
||||
-----------------
|
||||
|
||||
To monitor whether your pretix instance is running, you can issue a GET request to
|
||||
``https://pretix.mydomain.com/healthcheck/``. This endpoint tests if the connection to the
|
||||
database, to the configured cache and to redis (if used) is working correctly. If everything
|
||||
appears to work fine, an empty response with status code ``200`` is returned.
|
||||
If there is a problem, a status code in the ``5xx`` range will be returned.
|
||||
|
||||
.. _`perf-monitoring`:
|
||||
|
||||
Performance monitoring
|
||||
----------------------
|
||||
|
||||
If you to generate detailled performance statistics of your pretix installation, there is an
|
||||
endpoint at ``https://pretix.mydomain.com/metrics`` (no slash at the end) which returns a
|
||||
number of values in the text format understood by monitoring tools like Prometheus_. This data
|
||||
is only collected and exposed if you enable it in the :ref:`metrics-settings` section of your
|
||||
pretix configuration. You can also configure basic auth credentials there to protect your
|
||||
statistics against unauthorized access. The data is temporarily collected in redis, so the
|
||||
performance impact of this feature depends on the connection to your redis database.
|
||||
|
||||
Currently, mostly response times of HTTP requests and background tasks are exposed.
|
||||
|
||||
If you want to go even further, you can set the ``profile`` option in the :ref:`django-settings`
|
||||
section to a value between 0 and 1. If you set it for example to 0.1, then 10% of your requests
|
||||
(randomly selected) will be run with cProfile_ activated. The profiling results will be saved
|
||||
to your data directory. As this might impact performance significantly and writes a lot of data
|
||||
to disk, we recommend to only enable it for a small number of requests -- and only if you are
|
||||
really interested in the results.
|
||||
|
||||
Available metrics
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
The metrics available in pretix follow the standard `metric types`_ from the Prometheus world.
|
||||
Currently, the following metrics are exported:
|
||||
|
||||
pretix_view_requests_total
|
||||
Counter. Counts requests to Django views, labeled with the resolved ``url_name``, the used
|
||||
HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_view_durations_seconds
|
||||
Histogram. Measures duration of requests to Django views, labeled with the resolved
|
||||
``url_name``, the used HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_task_runs_total
|
||||
Counter. Counts executions of background tasks, labeled with the ``task_name`` and the
|
||||
``status``. The latter can be ``success``, ``error`` or ``expected-error``.
|
||||
|
||||
pretix_task_duration_seconds
|
||||
Histogram. Measures duration of successful background task executions, labeled with the
|
||||
``task_name``.
|
||||
|
||||
pretix_model_instances
|
||||
Gauge. Measures number of instances of a certain model within the database, labeled with
|
||||
the ``model`` name.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
@@ -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
|
||||
""""""""""""
|
||||
@@ -25,7 +25,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
@@ -15,6 +15,7 @@ External Dependencies
|
||||
* Python 3.4 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``pyvenv`` for Python 3 (Debian package: ``python3-venv``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
@@ -82,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
|
||||
@@ -147,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,9 +26,11 @@ The following plugins are from independent third-party authors, so we can make
|
||||
no statements about their stability:
|
||||
|
||||
* `esPass ticket output`_
|
||||
* `IcePay integration`_
|
||||
|
||||
.. _SEPA direct debit: https://github.com/pretix/pretix-sepadebit
|
||||
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
|
||||
.. _Cartshare: https://github.com/pretix/pretix-cartshare
|
||||
.. _Pages: https://github.com/pretix/pretix-pages
|
||||
.. _esPass ticket output: https://github.com/esPass/pretix-espass
|
||||
.. _IcePay integration: https://github.com/chotee/pretix-icepay
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
@@ -9,7 +9,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import exporter # NOQA
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
@@ -3,7 +3,7 @@ import tempfile
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,9 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ from django import forms
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..signals import register_data_exporters, register_payment_providers
|
||||
|
||||
class OrderListExporter(BaseExporter):
|
||||
identifier = 'orderlistcsv'
|
||||
verbose_name = _('List of orders (CSV)')
|
||||
verbose_name = ugettext_lazy('List of orders (CSV)')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
@@ -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
|
||||
|
||||
@@ -104,7 +104,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
|
||||
class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'))
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')),
|
||||
|
||||
38
src/pretix/base/forms/validators.py
Normal file
38
src/pretix/base/forms/validators.py
Normal file
@@ -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
|
||||
@@ -47,10 +47,11 @@ def language(lng):
|
||||
|
||||
|
||||
class LazyLocaleException(Exception):
|
||||
def __init__(self, msg, msgargs=None):
|
||||
self.msg = msg
|
||||
self.msgargs = msgargs
|
||||
super().__init__(msg, msgargs)
|
||||
def __init__(self, *args):
|
||||
self.msg = args[0]
|
||||
self.msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
super().__init__(self.msg, self.msgargs)
|
||||
|
||||
def __str__(self):
|
||||
if self.msgargs:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild static files and language files"
|
||||
@@ -10,3 +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)
|
||||
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
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
redis = django_redis.get_redis_connection("redis")
|
||||
|
||||
REDIS_KEY_PREFIX = "pretix_metrics_"
|
||||
REDIS_KEY = "pretix_metrics"
|
||||
_INF = float("inf")
|
||||
_MINUS_INF = float("-inf")
|
||||
|
||||
|
||||
def _float_to_go_string(d):
|
||||
# inspired by https://github.com/prometheus/client_python/blob/master/prometheus_client/core.py
|
||||
if d == _INF:
|
||||
return '+Inf'
|
||||
elif d == _MINUS_INF:
|
||||
return '-Inf'
|
||||
elif math.isnan(d):
|
||||
return 'NaN'
|
||||
else:
|
||||
return repr(float(d))
|
||||
|
||||
|
||||
class Metric(object):
|
||||
@@ -34,7 +52,7 @@ class Metric(object):
|
||||
if len(labels) != len(self.labelnames):
|
||||
raise ValueError("Unknown labels used: {}".format(", ".join(set(labels) - set(self.labelnames))))
|
||||
|
||||
def _construct_metric_identifier(self, metricname, labels=None):
|
||||
def _construct_metric_identifier(self, metricname, labels=None, labelnames=None):
|
||||
"""
|
||||
Constructs the scrapable metricname usable in the output format.
|
||||
"""
|
||||
@@ -42,26 +60,36 @@ class Metric(object):
|
||||
return metricname
|
||||
else:
|
||||
named_labels = []
|
||||
for labelname in self.labelnames:
|
||||
named_labels.append('{}="{}",'.format(labelname, labels[labelname]))
|
||||
for labelname in (labelnames or self.labelnames):
|
||||
named_labels.append('{}="{}"'.format(labelname, labels[labelname]))
|
||||
|
||||
return metricname + "{" + ",".join(named_labels) + "}"
|
||||
|
||||
def _inc_in_redis(self, key, amount):
|
||||
def _inc_in_redis(self, key, amount, pipeline=None):
|
||||
"""
|
||||
Increments given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.incrbyfloat(rkey, amount)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hincrbyfloat(REDIS_KEY, key, amount)
|
||||
|
||||
def _set_in_redis(self, key, value):
|
||||
def _set_in_redis(self, key, value, pipeline=None):
|
||||
"""
|
||||
Sets given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.set(rkey, value)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hset(REDIS_KEY, key, value)
|
||||
|
||||
def _get_redis_pipeline(self):
|
||||
if settings.HAS_REDIS:
|
||||
return redis.pipeline()
|
||||
|
||||
def _execute_redis_pipeline(self, pipeline):
|
||||
if settings.HAS_REDIS:
|
||||
return pipeline.execute()
|
||||
|
||||
|
||||
class Counter(Metric):
|
||||
@@ -124,21 +152,79 @@ class Gauge(Metric):
|
||||
self._inc_in_redis(fullmetric, amount * -1)
|
||||
|
||||
|
||||
class Histogram(Metric):
|
||||
"""
|
||||
Histogram Metric Object
|
||||
"""
|
||||
|
||||
def __init__(self, name, helpstring, labelnames=None,
|
||||
buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, 30.0, _INF)):
|
||||
if list(buckets) != sorted(buckets):
|
||||
# This is probably an error on the part of the user,
|
||||
# so raise rather than sorting for them.
|
||||
raise ValueError('Buckets not in sorted order')
|
||||
|
||||
if buckets and buckets[-1] != _INF:
|
||||
buckets.append(_INF)
|
||||
|
||||
if len(buckets) < 2:
|
||||
raise ValueError('Must have at least two buckets')
|
||||
|
||||
self.buckets = buckets
|
||||
super().__init__(name, helpstring, labelnames)
|
||||
|
||||
def observe(self, amount, **kwargs):
|
||||
"""
|
||||
Stores a value in the histogram for the labels specified in kwargs.
|
||||
"""
|
||||
if amount < 0:
|
||||
raise ValueError("Amount must be greater than zero. Otherwise use inc().")
|
||||
|
||||
self._check_label_consistency(kwargs)
|
||||
|
||||
pipe = self._get_redis_pipeline()
|
||||
|
||||
countmetric = self._construct_metric_identifier(self.name + '_count', kwargs)
|
||||
self._inc_in_redis(countmetric, 1, pipeline=pipe)
|
||||
|
||||
summetric = self._construct_metric_identifier(self.name + '_sum', kwargs)
|
||||
self._inc_in_redis(summetric, amount, pipeline=pipe)
|
||||
|
||||
kwargs_le = dict(kwargs.items())
|
||||
for i, bound in enumerate(self.buckets):
|
||||
if amount <= bound:
|
||||
kwargs_le['le'] = _float_to_go_string(bound)
|
||||
bmetric = self._construct_metric_identifier(self.name + '_bucket', kwargs_le,
|
||||
labelnames=self.labelnames + ["le"])
|
||||
self._inc_in_redis(bmetric, 1, pipeline=pipe)
|
||||
|
||||
self._execute_redis_pipeline(pipe)
|
||||
|
||||
|
||||
def metric_values():
|
||||
"""
|
||||
Produces the scrapable textformat to be presented to the monitoring system
|
||||
Produces the the values to be presented to the monitoring system
|
||||
"""
|
||||
if not settings.HAS_REDIS:
|
||||
return ""
|
||||
metrics = defaultdict(dict)
|
||||
|
||||
metrics = {}
|
||||
# Metrics from redis
|
||||
if settings.HAS_REDIS:
|
||||
for key, value in redis.hscan_iter(REDIS_KEY):
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
for key in redis.scan_iter(match=REDIS_KEY_PREFIX + "*"):
|
||||
dkey = key.decode("utf-8")
|
||||
_, _, output_key = dkey.split("_", 2)
|
||||
value = float(redis.get(dkey).decode("utf-8"))
|
||||
# Aliases
|
||||
aliases = {
|
||||
'pretix_view_requests_total': 'pretix_view_duration_seconds_count'
|
||||
}
|
||||
for a, atarget in aliases.items():
|
||||
metrics[a] = metrics[atarget]
|
||||
|
||||
metrics[output_key] = value
|
||||
# Throwaway metrics
|
||||
for m in apps.get_models(): # Count all models
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -146,5 +232,9 @@ def metric_values():
|
||||
"""
|
||||
Provided metrics
|
||||
"""
|
||||
http_requests_total = Counter("http_requests_total", "Total number of HTTP requests made.", ["code", "handler", "method"])
|
||||
# usage: http_requests_total.inc(code="200", handler="/foo", method="GET")
|
||||
pretix_view_duration_seconds = Histogram("pretix_view_duration_seconds", "Return time of views.",
|
||||
["status_code", "method", "url_name"])
|
||||
pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a celery task",
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["task_name"])
|
||||
|
||||
@@ -135,18 +135,30 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _parse_csp(header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v.split(' ')
|
||||
return h
|
||||
|
||||
|
||||
def _render_csp(h):
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
|
||||
|
||||
|
||||
def _merge_csp(a, b):
|
||||
for k, v in a.items():
|
||||
if k in b:
|
||||
a[k] += b[k]
|
||||
|
||||
for k, v in b.items():
|
||||
if k not in a:
|
||||
a[k] = b[k]
|
||||
|
||||
|
||||
class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
def _parse_csp(self, header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v
|
||||
return h
|
||||
|
||||
def _render_csp(self, h):
|
||||
return "; ".join(k + ' ' + v for k, v in h.items())
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -155,23 +167,23 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
resp['X-XSS-Protection'] = '1'
|
||||
h = {
|
||||
'default-src': "{static}",
|
||||
'script-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'object-src': "'none'",
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'object-src': ["'none'"],
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'child-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'style-src': "{static}",
|
||||
'connect-src': "{dynamic} https://checkout.stripe.com",
|
||||
'img-src': "{static} data: https://*.stripe.com",
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}"],
|
||||
'connect-src': ["{dynamic}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "data:", "https://*.stripe.com"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': "{dynamic} https:",
|
||||
'form-action': ["{dynamic}", "https:"],
|
||||
}
|
||||
if 'Content-Security-Policy' in resp:
|
||||
h.update(self._parse_csp(resp['Content-Security-Policy']))
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
staticdomain = "'self'"
|
||||
dynamicdomain = "'self'"
|
||||
@@ -184,5 +196,5 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
else:
|
||||
staticdomain += " " + settings.SITE_URL
|
||||
dynamicdomain += " " + settings.SITE_URL
|
||||
resp['Content-Security-Policy'] = self._render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
return resp
|
||||
|
||||
File diff suppressed because one or more lines are too long
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-24 15:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20170206_2027'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
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'),
|
||||
),
|
||||
]
|
||||
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
File diff suppressed because one or more lines are too long
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
@@ -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')]),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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, EventPermission, 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, OrganizerPermission
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -11,22 +11,21 @@ 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 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 ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
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 +58,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,
|
||||
@@ -83,6 +83,7 @@ class Event(LoggedModel):
|
||||
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,
|
||||
@@ -181,17 +182,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,7 +225,9 @@ class Event(LoggedModel):
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemCategory, Item, Question, Quota
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.save()
|
||||
|
||||
@@ -264,6 +256,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 +269,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 +287,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,6 +300,8 @@ 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)
|
||||
|
||||
@@ -122,6 +122,7 @@ class Invoice(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('event', 'invoice_no')
|
||||
ordering = ('invoice_no',)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
|
||||
@@ -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
|
||||
@@ -44,6 +45,13 @@ 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.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product category")
|
||||
@@ -51,6 +59,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):
|
||||
@@ -111,6 +121,10 @@ class Item(LoggedModel):
|
||||
:type hide_without_voucher: bool
|
||||
:param allow_cancel: If set to ``False``, an order with this product can not be canceled by the user.
|
||||
: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(
|
||||
@@ -151,7 +165,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"),
|
||||
@@ -203,6 +218,21 @@ 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,
|
||||
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.')
|
||||
)
|
||||
# !!! 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")
|
||||
@@ -280,6 +310,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
|
||||
@@ -297,6 +329,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")
|
||||
@@ -360,6 +397,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,
|
||||
@@ -591,7 +665,7 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
@@ -614,6 +688,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))
|
||||
@@ -633,6 +708,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)
|
||||
@@ -646,14 +722,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
|
||||
@@ -479,19 +489,26 @@ class OrderPosition(AbstractPosition):
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
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,
|
||||
@@ -59,14 +58,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
|
||||
|
||||
@@ -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()
|
||||
@@ -15,26 +15,50 @@ import time
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pretix.base.metrics import (
|
||||
pretix_task_duration_seconds, pretix_task_runs_total,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class ProfiledTask(app.Task):
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
|
||||
if settings.PROFILING_RATE > 0 and random.random() < settings.PROFILING_RATE / 100:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
starttime = time.time()
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
profiler.disable()
|
||||
tottime = time.time() - starttime
|
||||
profiler.dump_stats(os.path.join(settings.PROFILE_DIR, '{time:.0f}_{tottime:.3f}_celery_{t}.pstat'.format(
|
||||
t=self.name, tottime=tottime, time=time.time()
|
||||
)))
|
||||
return ret
|
||||
else:
|
||||
return super().__call__(*args, **kwargs)
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_duration_seconds.observe(tottime, task_name=self.name)
|
||||
return ret
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||
if settings.METRICS_ENABLED:
|
||||
expected = False
|
||||
for t in self.throws:
|
||||
if isinstance(exc, t):
|
||||
expected = True
|
||||
break
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="expected-error" if expected else "error")
|
||||
|
||||
return super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||
|
||||
def on_success(self, retval, task_id, args, kwargs):
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="success")
|
||||
|
||||
return super().on_success(retval, task_id, args, kwargs)
|
||||
|
||||
|
||||
class TransactionAwareTask(ProfiledTask):
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
@@ -27,12 +27,17 @@ 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.'),
|
||||
'in_part': _('Some of the products you selected are no longer available in '
|
||||
'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.'),
|
||||
@@ -44,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'))
|
||||
@@ -73,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))
|
||||
@@ -96,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
|
||||
@@ -110,12 +128,13 @@ 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,))
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
@@ -131,6 +150,36 @@ class CartManager:
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
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
|
||||
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
||||
op.count -
|
||||
len([1 for _op in self._operations
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
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]):
|
||||
price = item.default_price if variation is None else (
|
||||
@@ -143,7 +192,7 @@ class CartManager:
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
return error_messages['price_too_high']
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
if self.event.settings.display_net_prices:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
@@ -216,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)
|
||||
@@ -225,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 = {}
|
||||
@@ -278,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:
|
||||
@@ -322,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:
|
||||
@@ -353,42 +554,85 @@ class CartManager:
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.add_new_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.add_new_items(items)
|
||||
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 remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> 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
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
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()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
@@ -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
|
||||
@@ -138,7 +138,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")
|
||||
|
||||
@@ -45,6 +45,8 @@ error_messages = {
|
||||
'meantime. Please see below for details.'),
|
||||
'internal': _("An internal error occured, please try again."),
|
||||
'empty': _("Your cart is empty."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the "
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
@@ -206,8 +208,10 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
for i, cp in enumerate(positions):
|
||||
if not cp.item.active or (cp.variation and not cp.variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
@@ -215,6 +219,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
continue
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
err = error_messages['max_items_per_product']
|
||||
errargs = {'max': cp.item.max_per_order,
|
||||
'product': cp.item.name}
|
||||
cp.delete() # Sorry!
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
@@ -286,7 +298,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
else:
|
||||
cp.delete() # Sorry!
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
@@ -378,37 +390,36 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
if not order.invoices.exists():
|
||||
generate_invoice(order)
|
||||
|
||||
with language(order.locale):
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
|
||||
return order.id
|
||||
|
||||
@@ -551,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
|
||||
@@ -563,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()
|
||||
|
||||
@@ -659,13 +682,14 @@ class OrderChangeManager:
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
|
||||
try:
|
||||
with language(locale):
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
|
||||
125
src/pretix/base/services/update_check.py
Normal file
125
src/pretix/base/services/update_check.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def run_update_check(sender, **kwargs):
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_last or now() - gs.settings.update_check_last > timedelta(hours=23):
|
||||
update_check.apply_async()
|
||||
|
||||
|
||||
@app.task
|
||||
def update_check():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_id:
|
||||
gs.settings.set('update_check_id', uuid.uuid4().hex)
|
||||
|
||||
if 'runserver' in sys.argv:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'development'
|
||||
})
|
||||
return
|
||||
|
||||
check_payload = {
|
||||
'id': gs.settings.get('update_check_id'),
|
||||
'version': __version__,
|
||||
'events': {
|
||||
'total': Event.objects.count(),
|
||||
'live': Event.objects.filter(live=True).count(),
|
||||
},
|
||||
'plugins': [
|
||||
{
|
||||
'name': p.module,
|
||||
'version': p.version
|
||||
} for p in get_all_plugins()
|
||||
]
|
||||
}
|
||||
try:
|
||||
r = requests.post('https://pretix.eu/.update_check/', json=check_payload)
|
||||
gs.settings.set('update_check_last', now())
|
||||
if r.status_code != 200:
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'http_error'
|
||||
})
|
||||
else:
|
||||
rdata = r.json()
|
||||
update_available = rdata['version']['updatable'] or any(p['updatable'] for p in rdata['plugins'].values())
|
||||
gs.settings.set('update_check_result_warning', update_available)
|
||||
if update_available and rdata != gs.settings.update_check_result:
|
||||
send_update_notification_email()
|
||||
gs.settings.set('update_check_result', rdata)
|
||||
except requests.RequestException:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'unavailable'
|
||||
})
|
||||
|
||||
|
||||
def send_update_notification_email():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_email:
|
||||
return
|
||||
|
||||
mail(
|
||||
gs.settings.update_check_email,
|
||||
_('pretix update available'),
|
||||
LazyI18nString.from_gettext(
|
||||
ugettext_noop(
|
||||
'Hi!\n\nAn update is available for pretix or for one of the plugins you installed in your '
|
||||
'pretix installation. Please click on the following link for more information:\n\n {url} \n\n'
|
||||
'You can always find information on the latest updates on the pretix.eu blog:\n\n'
|
||||
'https://pretix.eu/about/en/blog/'
|
||||
'\n\nBest,\n\nyour pretix developers'
|
||||
)
|
||||
),
|
||||
{
|
||||
'url': build_absolute_uri('control:global.update')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def check_result_table():
|
||||
gs = GlobalSettingsObject()
|
||||
res = gs.settings.update_check_result
|
||||
if not res:
|
||||
return {
|
||||
'error': 'no_result'
|
||||
}
|
||||
|
||||
if 'error' in res:
|
||||
return res
|
||||
|
||||
table = []
|
||||
table.append(('pretix', __version__, res['version']['latest'], res['version']['updatable']))
|
||||
for p in get_all_plugins():
|
||||
if p.module in res['plugins']:
|
||||
pdata = res['plugins'][p.module]
|
||||
table.append((_('Plugin: %s') % p.name, p.version, pdata['latest'], pdata['updatable']))
|
||||
else:
|
||||
table.append((_('Plugin: %s') % p.name, p.version, '?', False))
|
||||
|
||||
return table
|
||||
@@ -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,10 @@ DEFAULTS = {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
@@ -351,197 +358,58 @@ Your {event} team"""))
|
||||
'frontpage_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'update_check_ack': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_email': {
|
||||
'default': '',
|
||||
'type': str
|
||||
},
|
||||
'update_check_perform': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_result': {
|
||||
'default': None,
|
||||
'type': dict
|
||||
},
|
||||
'update_check_result_warning': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_last': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'update_check_id': {
|
||||
'default': None,
|
||||
'type': str
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -586,13 +454,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
|
||||
@@ -52,6 +53,13 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
return responses
|
||||
|
||||
|
||||
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(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
.header h1 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 a {
|
||||
@@ -117,7 +117,7 @@
|
||||
<tr>
|
||||
<td class="header" background="">
|
||||
{% if event %}
|
||||
<h1><a href="{% eventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
{% else %}
|
||||
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
|
||||
{% endif %}
|
||||
@@ -141,7 +141,7 @@
|
||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% eventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ ALLOWED_TAGS = [
|
||||
'acronym',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'em',
|
||||
'i',
|
||||
@@ -25,6 +26,8 @@ ALLOWED_TAGS = [
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'div',
|
||||
'span'
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
@@ -33,6 +36,9 @@ ALLOWED_ATTRIBUTES = {
|
||||
'acronym': ['title'],
|
||||
'table': ['width'],
|
||||
'td': ['width', 'align'],
|
||||
'div': ['class'],
|
||||
'p': ['class'],
|
||||
'span': ['class'],
|
||||
}
|
||||
|
||||
|
||||
@@ -41,5 +47,9 @@ def rich_text(text: str, **kwargs):
|
||||
"""
|
||||
Processes markdown and cleans HTML in a text input.
|
||||
"""
|
||||
body_md = bleach.linkify(bleach.clean(markdown.markdown(text), tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES))
|
||||
body_md = bleach.linkify(bleach.clean(
|
||||
markdown.markdown(text),
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
))
|
||||
return mark_safe(body_md)
|
||||
|
||||
@@ -51,10 +51,15 @@ 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.
|
||||
"""
|
||||
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
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
|
||||
@@ -9,7 +9,6 @@ from django.shortcuts import redirect, render
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import casual_reads
|
||||
|
||||
logger = logging.getLogger('pretix.base.async')
|
||||
|
||||
@@ -32,11 +31,10 @@ class AsyncAction:
|
||||
return JsonResponse(data)
|
||||
else:
|
||||
if res.ready():
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
return redirect(self.get_check_url(res.id, False))
|
||||
|
||||
def get_success_url(self, value):
|
||||
@@ -66,25 +64,26 @@ class AsyncAction:
|
||||
'ready': ready
|
||||
}
|
||||
if ready:
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
smes = self.get_success_message(res.info)
|
||||
if smes:
|
||||
messages.success(self.request, smes)
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_success_url(res.info),
|
||||
'message': str(self.get_success_message(res.info))
|
||||
})
|
||||
else:
|
||||
messages.error(self.request, self.get_error_message(res.info))
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
smes = self.get_success_message(res.info)
|
||||
if smes:
|
||||
messages.success(self.request, smes)
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# 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:
|
||||
messages.error(self.request, self.get_error_message(res.info))
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'success': False,
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
return data
|
||||
|
||||
def get_result(self, request):
|
||||
@@ -93,11 +92,10 @@ class AsyncAction:
|
||||
return JsonResponse(self._return_ajax_result(res, timeout=0.25))
|
||||
else:
|
||||
if res.ready():
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
return render(request, 'pretixpresale/waiting.html')
|
||||
|
||||
def success(self, value):
|
||||
@@ -107,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))
|
||||
})
|
||||
@@ -117,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))
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.core import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
from ..models import User
|
||||
@@ -6,4 +8,18 @@ from ..models import User
|
||||
def healthcheck(request):
|
||||
# Perform a simple DB query to see that DB access works
|
||||
User.objects.exists()
|
||||
|
||||
# Test if redis access works
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
|
||||
redis = django_redis.get_redis_connection("redis")
|
||||
redis.set("_healthcheck", 1)
|
||||
if not redis.exists("_healthcheck"):
|
||||
return HttpResponse("Redis not available.", status=503)
|
||||
|
||||
cache.cache.set("_healthcheck", "1")
|
||||
if not cache.cache.get("_healthcheck") == "1":
|
||||
return HttpResponse("Cache not available.", status=503)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import hmac
|
||||
|
||||
from django.conf import settings
|
||||
@@ -26,7 +27,7 @@ def serve_metrics(request):
|
||||
if method.lower() != "basic":
|
||||
return unauthed_response()
|
||||
|
||||
user, passphrase = credentials.strip().decode("base64").split(":", 1)
|
||||
user, passphrase = base64.b64decode(credentials.strip()).decode().split(":", 1)
|
||||
|
||||
if not hmac.compare_digest(user, settings.METRICS_USER):
|
||||
return unauthed_response()
|
||||
@@ -37,9 +38,10 @@ def serve_metrics(request):
|
||||
m = metrics.metric_values()
|
||||
|
||||
output = []
|
||||
for metric, value in m:
|
||||
output.append("{} {}".format(metric, str(value)))
|
||||
for metric, sub in m.items():
|
||||
for label, value in sub.items():
|
||||
output.append("{}{} {}".format(metric, label, str(value)))
|
||||
|
||||
content = "\n".join(output)
|
||||
content = "\n".join(output) + "\n"
|
||||
|
||||
return HttpResponse(content)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
|
||||
from .signals import html_head, nav_event, nav_topbar
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
|
||||
@@ -34,8 +38,14 @@ 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'] = _nav_global
|
||||
|
||||
_nav_topbar = []
|
||||
for receiver, response in nav_topbar.send(request, request=request):
|
||||
@@ -46,4 +56,18 @@ def contextprocessor(request):
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
ctx['development_warning'] = True
|
||||
|
||||
ctx['warning_update_available'] = False
|
||||
ctx['warning_update_check_active'] = False
|
||||
if request.user.is_superuser:
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.update_check_result_warning:
|
||||
ctx['warning_update_available'] = True
|
||||
if not gs.settings.update_check_ack and 'runserver' not in sys.argv:
|
||||
ctx['warning_update_check_active'] = True
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -5,9 +5,9 @@ from django.core.validators import RegexValidator
|
||||
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
|
||||
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
|
||||
|
||||
@@ -61,9 +61,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):
|
||||
@@ -76,12 +78,27 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data['locale'] not in self.locales:
|
||||
if data.get('locale') not in self.locales:
|
||||
raise ValidationError({
|
||||
'locale': _('Your default locale must also be enabled for your event (see box above).')
|
||||
})
|
||||
if data.get('timezone') not in common_timezones:
|
||||
raise ValidationError({
|
||||
'timezone': _('Your default locale must be specified.')
|
||||
})
|
||||
|
||||
# change timezone
|
||||
zone = timezone(data.get('timezone'))
|
||||
data['date_from'] = self.reset_timezone(zone, data.get('date_from'))
|
||||
data['date_to'] = self.reset_timezone(zone, data.get('date_to'))
|
||||
data['presale_start'] = self.reset_timezone(zone, data.get('presale_start'))
|
||||
data['presale_end'] = self.reset_timezone(zone, data.get('presale_end'))
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def reset_timezone(tz, dt):
|
||||
return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data['slug']
|
||||
if Event.objects.filter(slug=slug, organizer=self.organizer).exists():
|
||||
@@ -136,9 +153,10 @@ 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'}),
|
||||
'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'}),
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +185,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(
|
||||
@@ -182,7 +201,8 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
locales = forms.MultipleChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
label=_("Available langauges"),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
label=_("Available languages"),
|
||||
)
|
||||
locale = forms.ChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
@@ -205,28 +225,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,
|
||||
@@ -244,7 +284,7 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Allow user to cancel unpaid orders"),
|
||||
help_text=_("If unchecked, users cannot cancel orders by themselves"),
|
||||
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -258,6 +298,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
|
||||
|
||||
|
||||
@@ -330,7 +374,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()
|
||||
@@ -350,11 +394,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(
|
||||
@@ -426,37 +472,44 @@ class MailSettingsForm(SettingsForm):
|
||||
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"),
|
||||
@@ -469,13 +522,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"),
|
||||
@@ -561,7 +616,13 @@ 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'}),
|
||||
)
|
||||
|
||||
def prepare_fields(self):
|
||||
@@ -570,6 +631,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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextInput
|
||||
|
||||
@@ -32,3 +33,26 @@ class GlobalSettingsForm(SettingsForm):
|
||||
for key, value in response.items():
|
||||
# We need to be this explicit, since OrderedDict.update does not retain ordering
|
||||
self.fields[key] = value
|
||||
|
||||
|
||||
class UpdateSettingsForm(SettingsForm):
|
||||
update_check_perform = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Perform update checks"),
|
||||
help_text=_("During the update check, pretix will report an anonymous, unique installation ID, "
|
||||
"the current version of pretix and your installed plugins and the number of active and "
|
||||
"inactive events in your installation to servers operated by the pretix developers. We "
|
||||
"will only store anonymous data, never any IP adresses and we will not know who you are "
|
||||
"or where to find your instance. You can disable this behaviour here at any time.")
|
||||
)
|
||||
update_check_email = forms.EmailField(
|
||||
required=False,
|
||||
label=_("E-mail notifications"),
|
||||
help_text=_("We will notify you at this address if we detect that a new update is available. This "
|
||||
"address will not be transmitted to pretix.eu, the emails will be sent by this server "
|
||||
"locally.")
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = GlobalSettingsObject()
|
||||
super().__init__(*args, obj=self.obj, **kwargs)
|
||||
|
||||
@@ -11,6 +11,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 +20,8 @@ class CategoryForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'description'
|
||||
'description',
|
||||
'is_addon'
|
||||
]
|
||||
|
||||
|
||||
@@ -108,6 +110,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(),
|
||||
@@ -138,6 +141,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'category',
|
||||
'admission',
|
||||
'default_price',
|
||||
'tax_rate',
|
||||
@@ -167,7 +171,9 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'available_until',
|
||||
'require_voucher',
|
||||
'hide_without_voucher',
|
||||
'allow_cancel'
|
||||
'allow_cancel',
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
]
|
||||
widgets = {
|
||||
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -205,4 +211,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]
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
|
||||
|
||||
class OrganizerForm(I18nModelForm):
|
||||
@@ -25,9 +26,42 @@ 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
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
This is a regular django signal (no pretix event signal).
|
||||
Deprecated signal, no longer works. We just keep the definition so old plugins don't
|
||||
break the installation.
|
||||
"""
|
||||
|
||||
|
||||
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,6 +32,7 @@
|
||||
<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>
|
||||
{% endcompress %}
|
||||
@@ -78,7 +79,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>
|
||||
@@ -105,8 +106,15 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if warning_update_available %}
|
||||
<li>
|
||||
<a href="{% url 'control:global.update' %}" class="danger">
|
||||
<i class="fa fa-bell"></i>
|
||||
</a>
|
||||
</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>
|
||||
@@ -141,7 +149,8 @@
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li>
|
||||
<a href="{% url 'control:global-settings' %}" {% if "global-settings" in url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.settings' %}"
|
||||
{% if "global.settings" in url_name %}class="active"{% endif %}>
|
||||
<i class="fa fa-wrench fa-fw"></i>
|
||||
{% trans "Global settings" %}
|
||||
</a>
|
||||
@@ -159,6 +168,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>
|
||||
@@ -173,6 +208,25 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if warning_update_check_active %}
|
||||
<div class="alert alert-info">
|
||||
<a href="{% url "control:global.update" %}">
|
||||
{% blocktrans trimmed %}
|
||||
Starting with version 1.2.0, pretix automatically checks for updates in the background.
|
||||
During this check, anonymous data is transmitted to servers operated by pretix'
|
||||
developers. Click on this message to find out more, disable this feature or enter your
|
||||
email address to get notified via email if a new update arrives. This message will
|
||||
disappear once you clicked it.
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if debug_warning %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
@@ -182,6 +236,9 @@
|
||||
powered by <a {{ a_attr }}>pretix</a>
|
||||
{% endblocktrans %}
|
||||
{% endwith %}
|
||||
{% if development_warning %}
|
||||
<span class="text-warning">· {% trans "running in development mode" %}</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -32,12 +32,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,7 +2,8 @@
|
||||
{% 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>
|
||||
@@ -13,106 +14,26 @@
|
||||
<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>
|
||||
@@ -72,8 +72,8 @@
|
||||
<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_change_vouchers }}</td>
|
||||
<td>{{ add_form.can_view_vouchers }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -41,6 +41,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>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% 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" %}
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default ticketoutput-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block title %}{% trans "Global settings" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Global settings" %}</h1>
|
||||
{% block inner %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block title %}{% trans "Global settings" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Global settings" %}</h1>
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "global.settings" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.settings' %}">
|
||||
{% trans "General" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "global.update" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.update' %}">
|
||||
{% trans "Update check" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% block inner %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block inner %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Update check results" %}</legend>
|
||||
{% if not gs.settings.update_check_perform %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "Update checks are disabled." %}
|
||||
</div>
|
||||
{% elif not gs.settings.update_check_last %}
|
||||
<div class="alert alert-info">
|
||||
{% trans "No update check has been performed yet since the last update of this installation. Update checks are performed on a daily basis if your cronjob is set up properly." %}
|
||||
</div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% elif "error" in gs.settings.update_check_result %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The last update check was not successful." %}
|
||||
{% if gs.settings.update_check_result.error == "http_error" %}
|
||||
{% trans "The pretix.eu server returned an error code." %}
|
||||
{% elif gs.settings.update_check_result.error == "unavailable" %}
|
||||
{% trans "The pretix.eu server could not be reached." %}
|
||||
{% elif gs.settings.update_check_result.error == "development" %}
|
||||
{% trans "This installation appears to be a development installation." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed with date=gs.settings.update_check_last|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Last updated: {{ date }}
|
||||
{% endblocktrans %}
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default btn-xs">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Component" %}</th>
|
||||
<th>{% trans "Installed version" %}</th>
|
||||
<th>{% trans "Latest version" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in tbl %}
|
||||
<tr class="{% if row.3 %}danger{% elif row.2 == "?" %}warning{% else %}success{% endif %}">
|
||||
<td>{{ row.0 }}</td>
|
||||
<td>{{ row.1 }}</td>
|
||||
<td>{{ row.2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Update check settings" %}</legend>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -6,12 +6,15 @@
|
||||
<p class="meta">
|
||||
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if log.user %}
|
||||
<br/><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 %}
|
||||
</p>
|
||||
|
||||
|
||||
96
src/pretix/control/templates/pretixcontrol/item/addons.html
Normal file
96
src/pretix/control/templates/pretixcontrol/item/addons.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends "pretixcontrol/item/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load formset_tags %}
|
||||
{% block inside %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
With add-ons, you can specify products that can be bought as an addition to this product. For example, if
|
||||
you host a conference with a base conference ticket and a number of workshops, you could define the
|
||||
workshops as add-ons to the conference ticket. With this configuration, the workshops cannot be bought
|
||||
on their own but only in combination with a conference ticket. You can here specify categories of products
|
||||
that can be used as add-ons to this product. You can also specify the minimum and maximum number of
|
||||
add-ons of the given category that can or need to be chosen. The user can buy every add-on from the
|
||||
category at most once. If an add-on product has multiple variations, only one of them can be bought.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<form class="form-horizontal branches" method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<h3 class="panel-title">{% trans "Add-On" %}</h3>
|
||||
</div>
|
||||
<div class="col-sm-4 text-right">
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field form.max_count layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<h3 class="panel-title">{% trans "Add-On" %}</h3>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.max_count layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new add-on" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -4,20 +4,25 @@
|
||||
{% block content %}
|
||||
{% if object.id %}
|
||||
<h1>{% trans "Modify product:" %} {{ object.name }}</h1>
|
||||
{% if object.has_variations %}
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "event.item" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "General information" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Variations" %}
|
||||
{% if object.has_variations %}
|
||||
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Variations" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li {% if "event.item.addons" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.addons' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Add-Ons" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1>{% trans "Create product" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
@@ -26,12 +31,12 @@
|
||||
{% endif %}
|
||||
{% if object.id and not object.quotas.exists %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
{% blocktrans trimmed %}
|
||||
Please note that your product will <strong>not</strong> be available for sale until you have added your
|
||||
item to an existing or newly created quota.
|
||||
{% endblocktrans %}
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block inside %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.copy_from layout="horizontal" %}
|
||||
{% bootstrap_field form.has_variations layout="horizontal" %}
|
||||
{% bootstrap_field form.category layout="horizontal" %}
|
||||
{% bootstrap_field form.admission layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<legend>{% trans "Availability" %}</legend>
|
||||
{% bootstrap_field form.available_from layout="horizontal" %}
|
||||
{% bootstrap_field form.available_until layout="horizontal" %}
|
||||
{% bootstrap_field form.max_per_order layout="horizontal" %}
|
||||
{% bootstrap_field form.min_per_order layout="horizontal" %}
|
||||
{% bootstrap_field form.require_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.allow_cancel layout="horizontal" %}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.active layout='horizontal' %}
|
||||
{% bootstrap_field form.default_price layout='horizontal' %}
|
||||
{% bootstrap_field form.description layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -69,6 +70,7 @@
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.active layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.default_price layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.description layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product categories" %}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -44,6 +44,7 @@
|
||||
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.description layout="horizontal" %}
|
||||
{% bootstrap_field form.is_addon layout="horizontal" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if category %}
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
<tr>
|
||||
<th>{% trans "Product name" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,6 +53,7 @@
|
||||
<a href="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<tr>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<th>{% trans "Products" %}</th>
|
||||
<th>{% trans "Total capacity" %}</th>
|
||||
<th>{% trans "Capacity left" %}</th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
{% if position.variation %}
|
||||
– {{ position.variation }}
|
||||
{% endif %}
|
||||
{% if position.addon_to %}
|
||||
– <em>
|
||||
{% blocktrans trimmed with posid=position.addon_to.positionid %}
|
||||
Add-On to position #{{ posid }}
|
||||
{% endblocktrans %}
|
||||
</em>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@@ -89,6 +96,11 @@
|
||||
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
|
||||
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
|
||||
{% trans "Remove from order" %}
|
||||
{% if position.addons.exists %}
|
||||
<em class="text-danger">
|
||||
{% trans "Removing this position will also remove all add-ons to this position." %}
|
||||
</em>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% trans "Change locale information" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Change locale information" %}
|
||||
</h1>
|
||||
<p>
|
||||
This language will be used whenever emails are sent to the users.
|
||||
</p>
|
||||
|
||||
<form method="post" class="form-horizontal" href="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="status" value="c" />
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
<div class="form-group submit-group">
|
||||
<a class="btn btn-default btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button class="btn btn-primary btn-save btn-lg" type="submit">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -73,6 +73,13 @@
|
||||
<dd>{{ order.code }}</dd>
|
||||
<dt>{% trans "Order date" %}</dt>
|
||||
<dd>{{ order.datetime }}</dd>
|
||||
<dt>{% trans "Order locale" %}</dt>
|
||||
<dd>
|
||||
{{ display_locale }}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
</dd>
|
||||
{% if order.status == "p" %}
|
||||
<dt>{% trans "Payment date" %}</dt>
|
||||
<dd>{{ order.payment_date }}</dd>
|
||||
@@ -159,7 +166,11 @@
|
||||
{% for line in items.positions %}
|
||||
<div class="row-fluid product-row">
|
||||
<div class="col-md-9 col-xs-6">
|
||||
#{{ line.positionid }} –
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
{% else %}
|
||||
#{{ line.positionid }} –
|
||||
{% endif %}
|
||||
<strong>{{ line.item.name }}</strong>
|
||||
{% if line.variation %}
|
||||
– {{ line.variation }}
|
||||
@@ -180,6 +191,11 @@
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_emails_asked %}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>{% if line.attendee_email %}{{ line.attendee_email }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
<h3 class="panel-title">{{ e.verbose_name }}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='horizontal' %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<form class="form-inline helper-display-inline"
|
||||
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
<div class="input-group">
|
||||
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}">
|
||||
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</h1>
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "organizer" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer" organizer=organizer.slug %}">
|
||||
{% trans "Events" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<li {% if "organizer.teams" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer.teams" organizer=organizer.slug %}">
|
||||
{% trans "Permissions" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for nav in nav_organizer %}
|
||||
<li {% if nav.active %}class="active"{% endif %}>
|
||||
<a href="{{ nav.url }}">
|
||||
{{ nav.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% block inner %}
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,159 +1,34 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</h1>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active">
|
||||
<a href="#tab-events" data-toggle="tab">{% trans "Events" %}</a>
|
||||
</li>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<li>
|
||||
<a href="#tab-permissions" data-toggle="tab">{% trans "Team" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for title, content in tabs %}
|
||||
<li>
|
||||
<a href="#tab-{{ forloop.counter }}" data-toggle="tab">
|
||||
{{ title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content organizer-tabs">
|
||||
<div class="tab-pane active" id="tab-events">
|
||||
<div class="tab-inner">
|
||||
{% if events|length == 0 %}
|
||||
<p>
|
||||
<em>{% trans "You currently do not have access to any events." %}</em>
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Event name" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.get_date_from_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<a href="{% url "control:events.add" %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new event" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<div class="tab-pane" id="tab-permissions">
|
||||
<div class="tab-inner">
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can use the following list to control who can create new events in the name of
|
||||
this organizer and who can add more people to this list. This does <strong>not</strong>
|
||||
control who has access to a particular event. You can control the access to an
|
||||
event in the "Permissions" section of the event's settings. A user does not need to
|
||||
be on the list here to get access to an event.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Everyone on this list can control the organizer settings on this page." %}
|
||||
</p>
|
||||
|
||||
{% 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 "Create events" %}</th>
|
||||
<th>{% trans "Change permissions" %}</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_create_events }}</td>
|
||||
<td>{{ form.can_change_permissions }}</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 team.
|
||||
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_create_events }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for title, content in tabs %}
|
||||
<div class="tab-pane" id="tab-{{ forloop.counter }}">
|
||||
<div class="tab-inner">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% block inner %}
|
||||
{% if events|length == 0 %}
|
||||
<p>
|
||||
<em>{% trans "You currently do not have access to any events." %}</em>
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Event name" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.get_date_from_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<a href="{% url "control:events.add" %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new event" %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.slug layout="horizontal" %}
|
||||
{% if form.domain %}
|
||||
{% bootstrap_field form.domain layout="horizontal" %}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can use the following list to control who can create new events in the name of
|
||||
this organizer and who can add more people to this list. This does <strong>not</strong>
|
||||
control who has access to a particular event. You can control the access to an
|
||||
event in the "Permissions" section of the event's settings. A user does not need to
|
||||
be on the list here to get access to an event.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Everyone on this list can control the organizer settings on this page." %}
|
||||
</p>
|
||||
|
||||
{% 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 "Create events" %}</th>
|
||||
<th>{% trans "Change permissions" %}</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_create_events }}</td>
|
||||
<td>{{ form.can_change_permissions }}</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 team.
|
||||
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_create_events }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -47,7 +47,7 @@
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Enabled devices" %}</h3>
|
||||
<h3 class="panel-title">{% trans "Registered devices" %}</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for d in devices %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
<input type="text" name="search" class="form-control" placeholder="{% trans "Search voucher" %}"
|
||||
value="{{ request.GET.search }}">
|
||||
value="{{ request.GET.search }}" autofocus>
|
||||
<input type="text" name="tag" class="form-control" placeholder="{% trans "Filter by tag" %}"
|
||||
value="{{ request.GET.tag }}">
|
||||
<select name="status" class="form-control">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user