forked from CGM_Public/pretix_original
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c60065b2 | ||
|
|
af4a2c7184 | ||
|
|
b6f42ecd6d | ||
|
|
8b7d2314b8 | ||
|
|
81adbb3813 | ||
|
|
684198fc08 | ||
|
|
a86431bb6d | ||
|
|
767e3ac659 | ||
|
|
910d6831bf | ||
|
|
c251a48e31 | ||
|
|
8e4b71eb19 | ||
|
|
a2cb219d9b | ||
|
|
f722d4e83e | ||
|
|
ed04f3124f | ||
|
|
08e7a29623 | ||
|
|
09020143e7 | ||
|
|
33e7a10bea | ||
|
|
5e64f6ac88 | ||
|
|
f16aabc136 | ||
|
|
2d00563088 | ||
|
|
124c3a99e6 | ||
|
|
7e135be012 | ||
|
|
d94c67bc7a | ||
|
|
3636bbbf3f | ||
|
|
7c687ee397 | ||
|
|
c3fb033d33 | ||
|
|
8b2257161f | ||
|
|
c4bf73c8d6 | ||
|
|
0db927407d | ||
|
|
9b7223c0e8 | ||
|
|
7b33fc6633 | ||
|
|
8310597944 | ||
|
|
c03ac624fc | ||
|
|
323beb1ab0 | ||
|
|
73490d2923 | ||
|
|
a8e630d271 | ||
|
|
e3e8a162bd | ||
|
|
824ca54478 | ||
|
|
8661bfe4a4 | ||
|
|
4c2c302bfd | ||
|
|
c83f539bba | ||
|
|
8f5849a90c | ||
|
|
b7df5eff19 | ||
|
|
eb4ba70be8 | ||
|
|
136094caf9 | ||
|
|
1fa0256363 | ||
|
|
6de44aee02 | ||
|
|
43facbecda | ||
|
|
0dfca824e2 | ||
|
|
70ee678fef | ||
|
|
680e7cb0da | ||
|
|
498ac97bdd | ||
|
|
3c75072868 | ||
|
|
8e09aa9b54 | ||
|
|
c4fe2322a1 | ||
|
|
43abe14293 | ||
|
|
7146d984d0 | ||
|
|
5495cd749a | ||
|
|
ef93b8ae38 | ||
|
|
2d370aaf5a | ||
|
|
3037309711 | ||
|
|
01c3200258 | ||
|
|
bf9987e05f | ||
|
|
cd61c0833d | ||
|
|
90bdb30449 | ||
|
|
92c2dcaf25 | ||
|
|
a3cd8d151d | ||
|
|
ba455a3630 | ||
|
|
123f47ab39 | ||
|
|
d6503e3e48 | ||
|
|
4f4e5854f2 | ||
|
|
48461122f8 | ||
|
|
aba7652aee | ||
|
|
78fc58cc93 | ||
|
|
3a2ca8d3d6 | ||
|
|
fecc5ec307 | ||
|
|
0665bd443b | ||
|
|
221526c979 | ||
|
|
04369ff4f1 | ||
|
|
e71691d4a5 | ||
|
|
598e7c5637 | ||
|
|
ad73c0e05b | ||
|
|
d4573e8c25 | ||
|
|
1d0def19b1 | ||
|
|
80a1bcf033 | ||
|
|
2f0cbcc565 | ||
|
|
48a2090e01 | ||
|
|
520b978ab0 | ||
|
|
ec42557305 | ||
|
|
bbb71ef891 | ||
|
|
7300c60e73 | ||
|
|
9f90ac80a0 | ||
|
|
f2260212ee | ||
|
|
b94459c761 | ||
|
|
3e26a4d9cc | ||
|
|
6bcfa4980f | ||
|
|
829b6a7d56 | ||
|
|
56bf3fe459 | ||
|
|
63bdb397e7 | ||
|
|
d4b3bf4370 | ||
|
|
7120e95d2a | ||
|
|
f285390f46 | ||
|
|
559864dd01 | ||
|
|
8021e1f269 | ||
|
|
53713acd9a | ||
|
|
5212f6b035 | ||
|
|
ea807239b1 | ||
|
|
2ec534e32d | ||
|
|
ec90efbf4a | ||
|
|
ffa35a9b9b | ||
|
|
2a6629e075 | ||
|
|
59d440b213 | ||
|
|
e6a6043a7a | ||
|
|
ecb1eedcba | ||
|
|
41c8ed2400 |
35
.codecov.yml
Normal file
35
.codecov.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
codecov:
|
||||
notify:
|
||||
require_ci_to_pass: yes
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "60...100"
|
||||
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 2%
|
||||
base: auto
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 2%
|
||||
base: auto
|
||||
changes: no
|
||||
|
||||
parsers:
|
||||
gcov:
|
||||
branch_detection:
|
||||
conditional: yes
|
||||
loop: yes
|
||||
method: no
|
||||
macro: no
|
||||
|
||||
comment:
|
||||
require_changes: yes
|
||||
layout: "header, diff, files"
|
||||
behavior: default
|
||||
require_changes: no
|
||||
12
.travis.sh
12
.travis.sh
@@ -4,6 +4,16 @@ set -x
|
||||
|
||||
echo "Executing job $1"
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_mysql.cfg" ]; then
|
||||
mysql -u root -e 'CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;'
|
||||
pip3 install -Ur src/requirements/mysql.txt
|
||||
fi
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
psql -c 'create database travis_ci_test;' -U postgres
|
||||
pip3 install -Ur src/requirements/postgres.txt
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
@@ -27,5 +37,5 @@ if [ "$1" == "tests-cov" ]; then
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && coveralls
|
||||
coverage run -m py.test --rerun 5 tests && codecov
|
||||
fi
|
||||
|
||||
43
.travis.yml
43
.travis.yml
@@ -1,15 +1,38 @@
|
||||
language: python
|
||||
sudo: false
|
||||
python:
|
||||
- "3.4"
|
||||
install:
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
script:
|
||||
- bash .travis.sh $JOB
|
||||
- bash .travis.sh $JOB
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
env:
|
||||
- JOB=style
|
||||
- JOB=doctests
|
||||
- JOB=tests-cov
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
matrix:
|
||||
include:
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/sqlite.cfg
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.4
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.4
|
||||
env: JOB=style
|
||||
- python: 3.4
|
||||
env: JOB=tests-cov
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
|
||||
1
AUTHORS
1
AUTHORS
@@ -21,3 +21,4 @@ an awesome project. Thank you all!
|
||||
Tobias Kunze <rixx@cutebit.de>
|
||||
Oliver Knapp <github@oliverknapp.de>
|
||||
Vishal Sodani <vishalsodani@rediffmail.com>
|
||||
Jan Felix Wiebe <git@jfwie.be>
|
||||
|
||||
66
Dockerfile
66
Dockerfile
@@ -1,50 +1,46 @@
|
||||
FROM debian:jessie
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 git python3-pip \
|
||||
libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \
|
||||
build-essential python3-dev zlib1g-dev libssl-dev gettext \
|
||||
libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \
|
||||
aqbanking-tools supervisor nginx sudo \
|
||||
--no-install-recommends
|
||||
|
||||
WORKDIR /
|
||||
|
||||
RUN dpkg-reconfigure locales && \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y python3 git python3-pip \
|
||||
libxml2-dev libxslt1-dev python-dev python-virtualenv locales libffi-dev \
|
||||
build-essential python3-dev zlib1g-dev libssl-dev gettext \
|
||||
libpq-dev libmysqlclient-dev libmemcached-dev libjpeg-dev \
|
||||
aqbanking-tools supervisor nginx sudo \
|
||||
--no-install-recommends && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8
|
||||
ENV LC_ALL C.UTF-8
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
mkdir /etc/pretix && \
|
||||
mkdir /data && \
|
||||
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
|
||||
echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers && \
|
||||
mkdir /static
|
||||
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -ms /bin/bash -d /pretix -u 15371 pretixuser
|
||||
RUN echo 'pretixuser ALL=(ALL) NOPASSWD: /usr/bin/supervisord' >> /etc/sudoers
|
||||
|
||||
RUN mkdir /etc/pretix
|
||||
RUN mkdir /data
|
||||
VOLUME /etc/pretix
|
||||
ENV LC_ALL=C.UTF-8 \
|
||||
DJANGO_SETTINGS_MODULE=production_settings
|
||||
|
||||
COPY deployment/docker/pretix.bash /usr/local/bin/pretix
|
||||
RUN chmod +x /usr/local/bin/pretix
|
||||
COPY deployment/docker/supervisord.conf /etc/supervisord.conf
|
||||
|
||||
COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
|
||||
RUN rm /etc/nginx/sites-enabled/default
|
||||
|
||||
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY src /pretix/src
|
||||
WORKDIR /pretix/src
|
||||
ADD deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
ENV DJANGO_SETTINGS_MODULE production_settings
|
||||
|
||||
RUN pip3 install -U pip wheel setuptools
|
||||
RUN pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn
|
||||
RUN chmod +x /usr/local/bin/pretix && \
|
||||
rm /etc/nginx/sites-enabled/default && \
|
||||
pip3 install -U pip wheel setuptools && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt \
|
||||
-r requirements/py34.txt gunicorn && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
sudo -u pretixuser make production
|
||||
|
||||
RUN mkdir /static && chown -R pretixuser:pretixuser /static /pretix /data
|
||||
USER pretixuser
|
||||
RUN make production
|
||||
|
||||
VOLUME ["/etc/pretix", "/data"]
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["pretix"]
|
||||
CMD ["all"]
|
||||
|
||||
38
README.rst
38
README.rst
@@ -10,25 +10,30 @@ pretix
|
||||
.. image:: https://travis-ci.org/pretix/pretix.svg?branch=master
|
||||
:target: https://travis-ci.org/pretix/pretix
|
||||
|
||||
.. image:: https://coveralls.io/repos/github/pretix/pretix/badge.svg?branch=master
|
||||
:target: https://coveralls.io/r/pretix/pretix
|
||||
.. image:: https://codecov.io/gh/pretix/pretix/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/pretix/pretix
|
||||
|
||||
|
||||
Reinventing ticket presales, one bit at a time.
|
||||
|
||||
Project status
|
||||
--------------
|
||||
Most features are present and sufficiently stable. pretix has been in use for multiple event and
|
||||
sold a few thousand tickets so far. There is still a bunch of features to come and there surely is
|
||||
still a bunch of bugs in there, but we consider it stable enough that we use it in production ourselves.
|
||||
Reinventing ticket presales, one ticket at a time.
|
||||
|
||||
If you deploy and use pretix, there will be a safe upgrade path for all changes to come. We're planning
|
||||
on an 1.0 release in late 2016 or early 2017. Until then, we take the liberty of changing the code as we
|
||||
like, but we try to keep the changes to documented APIs as small as possible. If you want to use pretix
|
||||
in production or develop a plugin now, I invite you to send me an email so that I can notify you of changes
|
||||
and bugs that require your attention.
|
||||
Project status & release cycle
|
||||
------------------------------
|
||||
|
||||
Since very recently we now have an `installation guide`_ in our documentation.
|
||||
While there is always a lot to do and improve on, pretix by now has been in use for more than a dozen
|
||||
conferences that sold over ten thousand tickets combined without major problems. We therefore think of
|
||||
pretix as being stable and ready to use.
|
||||
|
||||
If you want to use or extend pretix, we strongly recommend to follow our `blog`_. We will announce all
|
||||
releases there. You can always find the latest stable version on PyPI or in the ``release/X.Y`` branch of
|
||||
this repository. The ``master`` branch contains a development version that we also try to keep stable in
|
||||
the sense that it does not break your data, but its APIs might change without prior notice.
|
||||
|
||||
To get started using pretix on your own server, look at the `installation guide`_ in our documentation.
|
||||
|
||||
This project is 100 percent free and open source software. If you are interested in commercial support,
|
||||
hosting services or supporting this project financially, please go to `pretix.eu`_ or contact us at
|
||||
support@pretix.eu.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
@@ -43,10 +48,7 @@ See the LICENSE file for the complete license text.
|
||||
This project is maintained by Raphael Michel <mail@raphaelmichel.de>. See the
|
||||
AUTHORS file for a list of all the awesome folks who contributed to this project.
|
||||
|
||||
This project is 100 percent free and open source software. If you are interested in
|
||||
commercial support, hosting services or supporting this project financially, please
|
||||
go to `pretix.eu`_ or contact Raphael directly.
|
||||
|
||||
.. _installation guide: https://docs.pretix.eu/en/latest/admin/installation/index.html
|
||||
.. _developer documentation: https://docs.pretix.eu/en/latest/development/index.html
|
||||
.. _pretix.eu: https://pretix.eu
|
||||
.. _blog: https://pretix.eu/about/en/blog/
|
||||
|
||||
@@ -48,8 +48,16 @@ http {
|
||||
expires 7d;
|
||||
access_log off;
|
||||
}
|
||||
location ^~ /media/cachedfiles {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ^~ /media/invoices {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
alias /pretix/src/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from pretix.settings import *
|
||||
|
||||
|
||||
LOGGING['handlers']['mail_admins']['include_html'] = True
|
||||
STATIC_ROOT = '/static'
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
|
||||
@@ -102,6 +102,10 @@ Example::
|
||||
``user``, ``password``, ``host``, ``port``
|
||||
Connection details for the database connection. Empty by default.
|
||||
|
||||
``galera``
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
@@ -247,7 +251,7 @@ Sentry
|
||||
------
|
||||
|
||||
pretix has native support for sentry, a tool that you can use to track errors in the
|
||||
application. If you want to use sentry, you need to set a DSN in the configuration file.
|
||||
application. If you want to use sentry, you need to set a DSN in the configuration file::
|
||||
|
||||
[sentry]
|
||||
dsn=https://<key>:<secret>@sentry.io/<project>
|
||||
|
||||
@@ -111,7 +111,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with psycopg2 for PostgreSQL
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
name=pretix
|
||||
user=pretix
|
||||
@@ -141,9 +141,9 @@ See :ref:`email configuration <mail-settings>` to learn more about configuring m
|
||||
Docker image and service
|
||||
------------------------
|
||||
|
||||
First of all, download the latest pretix image by running::
|
||||
First of all, download the latest stable pretix image by running::
|
||||
|
||||
$ docker pull pretix/standalone:latest
|
||||
$ docker pull pretix/standalone:stable
|
||||
|
||||
We recommend starting the docker container using systemd to make sure it runs correctly after a reboot. Create a file
|
||||
named ``/etc/systemd/system/pretix.service`` with the following content::
|
||||
@@ -229,11 +229,13 @@ Updates
|
||||
|
||||
Updates are fairly simple, but require at least a short downtime::
|
||||
|
||||
# docker pull pretix/standalone
|
||||
# docker pull pretix/standalone:stable
|
||||
# systemctl restart pretix.service
|
||||
# docker exec -it pretix.service pretix upgrade
|
||||
|
||||
Restarting the service can take a few seconds, especially if the update requires changes to the database.
|
||||
Replace ``stable`` above with a specific version number like ``1.0`` or with ``latest`` for the development
|
||||
version, if you want to.
|
||||
|
||||
Install a plugin
|
||||
----------------
|
||||
|
||||
@@ -5,7 +5,7 @@ General remarks
|
||||
|
||||
Requirements
|
||||
------------
|
||||
To use pretix, you wull need the following things:
|
||||
To use pretix, you will need the following things:
|
||||
|
||||
* **pretix** and the python packages it depends on
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ Unix user
|
||||
|
||||
As we do not want to run pretix as root, we first create a new unprivileged user::
|
||||
|
||||
# sudo adduser pretix --disabled-password --home /var/pretix
|
||||
# adduser pretix --disabled-password --home /var/pretix
|
||||
|
||||
In this guide, all code lines prepended with a ``#`` symbol are commands that you need to execute on your server as
|
||||
``root`` user; all lines prepended with a ``$`` symbol should be run by the unprivileged user.
|
||||
``root`` user (e.g. using ``sudo``); all lines prepended with a ``$`` symbol should be run by the unprivileged user.
|
||||
|
||||
Database
|
||||
--------
|
||||
@@ -82,7 +82,7 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
datadir=/var/pretix/data
|
||||
|
||||
[database]
|
||||
; Replace mysql with psycopg2 for PostgreSQL
|
||||
; Replace mysql with postgresql_psycopg2 for PostgreSQL
|
||||
backend=mysql
|
||||
name=pretix
|
||||
user=pretix
|
||||
@@ -100,8 +100,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
|
||||
sessions=true
|
||||
|
||||
[celery]
|
||||
backend=redis://127.0.0.1?virtual_host=1
|
||||
broker=redis://127.0.0.1?virtual_host=2
|
||||
backend=redis://127.0.0.1/1
|
||||
broker=redis://127.0.0.1/2
|
||||
|
||||
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
|
||||
|
||||
@@ -119,7 +119,10 @@ python installation::
|
||||
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following
|
||||
command if you're running PostgreSQL::
|
||||
|
||||
(venv)$ pip install "pretix[mysql]" gunicorn
|
||||
(venv)$ pip3 install "pretix[mysql]" gunicorn
|
||||
|
||||
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer.
|
||||
You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
@@ -127,8 +130,8 @@ We also need to create a data directory::
|
||||
|
||||
Finally, we compile static files and translation data and create the database structure::
|
||||
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
|
||||
|
||||
Start pretix as a service
|
||||
@@ -150,7 +153,7 @@ named ``/etc/systemd/system/pretix-web.service`` with the following content::
|
||||
--name pretix --workers 5 \
|
||||
--max-requests 1200 --max-requests-jitter 50 \
|
||||
--log-level=info --bind=127.0.0.1:8345
|
||||
WorkingDirectory=/var/pretix/source/src
|
||||
WorkingDirectory=/var/pretix
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -168,7 +171,7 @@ For background tasks we need a second service ``/etc/systemd/system/pretix-worke
|
||||
Environment="VIRTUAL_ENV=/var/pretix/venv"
|
||||
Environment="PATH=/var/pretix/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
ExecStart=/var/pretix/venv/bin/celery -A pretix.celery_app worker -l info
|
||||
WorkingDirectory=/var/pretix/source/src
|
||||
WorkingDirectory=/var/pretix
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
@@ -187,7 +190,7 @@ Cronjob
|
||||
You need to set up a cronjob that runs the management command ``runperiodic``. The exact interval is not important
|
||||
but should be something between every minute and every hour. You could for example configure cron like this::
|
||||
|
||||
15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix/source/src && python -m pretix runperiodic
|
||||
15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix && python -m pretix runperiodic
|
||||
|
||||
The cronjob should run as the ``pretix`` user (``crontab -e -u pretix``).
|
||||
|
||||
@@ -223,14 +226,25 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ^~ /media/cachedfiles {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
location ^~ /media/invoices {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/pretix/source/src/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.5/site-packages/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
.. note:: Remember to replace the ``python3.5`` in the ``/static/`` path in the config
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ Order events
|
||||
There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: order_paid, order_placed
|
||||
:members: validate_cart, order_paid, order_placed
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, footer_links, front_page_top, front_page_bottom
|
||||
:members: html_head, 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
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, organizer_edit_tabs
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -42,6 +42,13 @@ configuration class. The metadata class must define the following attributes:
|
||||
``description`` (``str``):
|
||||
A more verbose description of what your plugin does.
|
||||
|
||||
``visible`` (``bool``):
|
||||
``True`` by default, can hide a plugin so it cannot be normally activated.
|
||||
|
||||
``restricted`` (``bool``):
|
||||
``False`` by default, restricts a plugin such that it can only be enabled for an event
|
||||
by system administrators / superusers.
|
||||
|
||||
A working example would be::
|
||||
|
||||
# file: pretix/plugins/timerestriction/__init__.py
|
||||
@@ -57,6 +64,8 @@ A working example would be::
|
||||
name = _("PayPal")
|
||||
author = _("the pretix team")
|
||||
version = '1.0.0'
|
||||
visible = True
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
|
||||
|
||||
|
||||
@@ -64,4 +64,6 @@ The output class
|
||||
|
||||
.. automethod:: generate
|
||||
|
||||
.. automethod:: generate_order
|
||||
|
||||
.. autoattribute:: download_button_text
|
||||
|
||||
@@ -6,116 +6,18 @@ One of pretix's major selling points is its multi-language capability. We make h
|
||||
way to translate *user-generated content*. In our case, we need to translate strings like product names
|
||||
or event descriptions, so we need event organizers to be able to fill in all fields in multiple languages.
|
||||
|
||||
.. note:: Implementing object-level translation in a relational database is a task that requires some difficult
|
||||
trade-off. We decided for a design that is not elegant on the database level (as it violates the `1NF`_) and
|
||||
makes searching in the respective database fields very hard, but allows for a simple design on the ORM level
|
||||
and adds only minimal performance overhead.
|
||||
For this purpose, we use ``django-i18nfield`` which started out as part of pretix and then got refactored into
|
||||
its own library. It has comprehensive documentation on how to work with its `strings`_, `database fields`_ and
|
||||
`forms`_.
|
||||
|
||||
All classes and functions introduced in this document are located in ``pretix.base.i18n`` if not stated otherwise.
|
||||
|
||||
Database storage
|
||||
----------------
|
||||
|
||||
pretix provides two custom model field types that allow you to work with localized strings: ``I18nCharField`` and
|
||||
``I18nTextField``. Both of them are stored in the database as a ``TextField`` internally, they only differ in the
|
||||
default form widget that is used by ``ModelForm``.
|
||||
|
||||
As pretix does not use these fields in places that need to be searched, the negative performance impact when searching
|
||||
and indexing these fields in negligible, as mentioned above. Lookups are currently not even implemented on these
|
||||
fields. In the database, the strings will be stored as a JSON-encoded mapping of language codes to strings.
|
||||
|
||||
Whenever you interact with those fields, you will either provide or receive an instance of the following class:
|
||||
|
||||
.. autoclass:: pretix.base.i18n.LazyI18nString
|
||||
:members: __init__, localize, __str__
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
The following examples are given to illustrate how you can work with ``LazyI18nString``.
|
||||
|
||||
.. testsetup:: *
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
|
||||
To create a LazyI18nString, we can cast a simple string:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> naive = LazyI18nString('Naive untranslated string')
|
||||
>>> naive
|
||||
<LazyI18nString: 'Naive untranslated string'>
|
||||
|
||||
Or we can provide a dictionary with multiple translations:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated = LazyI18nString({'en': 'English String', 'de': 'Deutscher String'})
|
||||
|
||||
We can use ``localize`` to get the string in a specific language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated.localize('de')
|
||||
'Deutscher String'
|
||||
|
||||
>>> translated.localize('en')
|
||||
'English String'
|
||||
|
||||
If we try a locale that does not exist for the string, we might get a it either in a similar locale or in the system's default language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translated.localize('de-AT')
|
||||
'Deutscher String'
|
||||
|
||||
>>> translated.localize('zh')
|
||||
'English String'
|
||||
|
||||
>>> naive.localize('de')
|
||||
'Naive untranslated string'
|
||||
|
||||
If we cast a ``LazyI18nString`` to ``str``, ``localize`` will be called with the currently active language:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> from django.utils import translation
|
||||
>>> str(translated)
|
||||
'English String'
|
||||
>>> translation.activate('de')
|
||||
>>> str(translated)
|
||||
'Deutscher String'
|
||||
|
||||
You can also use our handy context manager to set the locale temporarily:
|
||||
|
||||
.. doctest::
|
||||
|
||||
>>> translation.activate('en')
|
||||
>>> with language('de'):
|
||||
... str(translated)
|
||||
'Deutscher String'
|
||||
>>> str(translated)
|
||||
'English String'
|
||||
|
||||
Forms
|
||||
-----
|
||||
|
||||
We provide i18n-aware versions of the respective form fields and widgets: ``I18nFormField`` with the ``I18nTextInput``
|
||||
and ``I18nTextarea`` widgets. They transparently allow you to use ``LazyI18nString`` values in forms and render text
|
||||
inputs for multiple languages.
|
||||
|
||||
.. autoclass:: pretix.base.i18n.I18nFormField
|
||||
|
||||
To easily limit the displayed languages to the languages relevant to an event, there is a custom ``ModelForm`` subclass
|
||||
that deals with this for you:
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nModelForm
|
||||
|
||||
There are equivalents for ``BaseModelFormSet`` and ``BaseInlineFormSet``:
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nFormSet
|
||||
|
||||
.. autoclass:: pretix.base.forms.I18nInlineFormSet
|
||||
For backwards-compatibility with older parts of pretix' code base and older plugins, ``pretix.base.forms`` still
|
||||
contains a number of forms that are equivalent in name and usage to their counterparts in ``i18nfield.forms`` with
|
||||
the difference that they take an ``event`` keyword argument and then set the ``locales`` argument based on
|
||||
``event.settings.get('locales')``.
|
||||
|
||||
Useful utilities
|
||||
----------------
|
||||
@@ -135,4 +37,6 @@ action that causes the mail to be sent.
|
||||
|
||||
.. _translation features: https://docs.djangoproject.com/en/1.9/topics/i18n/translation/
|
||||
.. _GNU gettext: https://www.gnu.org/software/gettext/
|
||||
.. _1NF: https://en.wikipedia.org/wiki/First_normal_form
|
||||
.. _strings: https://django-i18nfield.readthedocs.io/en/latest/strings.html
|
||||
.. _database fields: https://django-i18nfield.readthedocs.io/en/latest/quickstart.html
|
||||
.. _forms: https://django-i18nfield.readthedocs.io/en/latest/forms.html
|
||||
|
||||
@@ -17,6 +17,8 @@ ways that pretix itself is:
|
||||
The following plugins are not shipped with pretix but are maintained by the
|
||||
same team:
|
||||
|
||||
* `SEPA direct debit`_
|
||||
* `Pages`_
|
||||
* `Passbook/Wallet ticket output`_
|
||||
* `Cartshare`_
|
||||
|
||||
@@ -25,6 +27,8 @@ no statements about their stability:
|
||||
|
||||
* `esPass ticket output`_
|
||||
|
||||
.. _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
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.0.0b2"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
@@ -19,6 +19,6 @@ class PretixBaseConfig(AppConfig):
|
||||
|
||||
default_app_config = 'pretix.base.PretixBaseConfig'
|
||||
try:
|
||||
import pretix.celery_app as celery # NOQA
|
||||
import pretix.celery_app as celery # NOQA
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytz
|
||||
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 pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
@@ -50,7 +51,7 @@ class OrderListExporter(BaseExporter):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
|
||||
|
||||
qs = self.event.orders.all().select_related('invoice_address')
|
||||
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
tax_rates = self._get_all_tax_rates(qs)
|
||||
@@ -58,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')
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'), _('Invoice numbers')
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -86,7 +87,7 @@ class OrderListExporter(BaseExporter):
|
||||
for order in qs.order_by('datetime'):
|
||||
row = [
|
||||
order.code,
|
||||
str(order.total),
|
||||
localize(order.total),
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
@@ -107,7 +108,7 @@ class OrderListExporter(BaseExporter):
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
provider_names.get(order.payment_provider, order.payment_provider),
|
||||
str(order.payment_fee)
|
||||
localize(order.payment_fee)
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -117,11 +118,12 @@ class OrderListExporter(BaseExporter):
|
||||
taxrate_values['taxsum'] += order.payment_fee_tax_value
|
||||
|
||||
row += [
|
||||
str(taxrate_values['grosssum']),
|
||||
str(taxrate_values['grosssum'] - taxrate_values['taxsum']),
|
||||
str(taxrate_values['taxsum']),
|
||||
localize(taxrate_values['grosssum']),
|
||||
localize(taxrate_values['grosssum'] - taxrate_values['taxsum']),
|
||||
localize(taxrate_values['taxsum']),
|
||||
]
|
||||
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
writer.writerow(row)
|
||||
|
||||
return 'orders.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
@@ -1,89 +1,55 @@
|
||||
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 (
|
||||
BaseInlineFormSet, BaseModelForm, BaseModelFormSet, ModelFormMetaclass,
|
||||
)
|
||||
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 pretix.base.i18n import I18nFormField
|
||||
from pretix.base.models import Event
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
|
||||
|
||||
|
||||
class BaseI18nModelForm(BaseModelForm):
|
||||
"""
|
||||
This is a helperclass to construct an I18nModelForm.
|
||||
"""
|
||||
class BaseI18nModelForm(i18nfield.forms.BaseI18nModelForm):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.pop('event', None)
|
||||
locales = kwargs.pop('locales', None)
|
||||
if event:
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
if event or locales:
|
||||
for k, field in self.fields.items():
|
||||
if isinstance(field, I18nFormField):
|
||||
field.widget.enabled_langcodes = event.settings.get('locales') if event else locales
|
||||
|
||||
|
||||
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)):
|
||||
"""
|
||||
This is a modified version of Django's ModelForm which differs from ModelForm in
|
||||
only one way: The constructor takes one additional optional argument ``event``
|
||||
expecting an `Event` instance. If given, this instance is used to select
|
||||
the visible languages in all I18nFormFields of the form. If not given, all languages
|
||||
will be displayed.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class I18nFormSet(BaseModelFormSet):
|
||||
"""
|
||||
This is equivalent to a normal BaseModelFormset, but cares for the special needs
|
||||
of I18nForms (see there for more information).
|
||||
"""
|
||||
class I18nFormSet(i18nfield.forms.I18nModelFormSet):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
event = kwargs.pop('event', None)
|
||||
if event:
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
@property
|
||||
def empty_form(self):
|
||||
form = self.form(
|
||||
auto_id=self.auto_id,
|
||||
prefix=self.add_prefix('__prefix__'),
|
||||
empty_permitted=True,
|
||||
event=self.event
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
|
||||
|
||||
class I18nInlineFormSet(BaseInlineFormSet):
|
||||
"""
|
||||
This is equivalent to a normal BaseInlineFormset, but cares for the special needs
|
||||
of I18nForms (see there for more information).
|
||||
"""
|
||||
class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
# compatibility shim for django-i18nfield library
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
event = kwargs.pop('event', None)
|
||||
if event:
|
||||
kwargs['locales'] = event.settings.get('locales')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
|
||||
class SettingsForm(forms.Form):
|
||||
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
|
||||
@@ -92,6 +58,7 @@ class SettingsForm(forms.Form):
|
||||
|
||||
:param obj: The event or organizer object which should be used for the settings storage
|
||||
"""
|
||||
|
||||
BOOL_CHOICES = (
|
||||
('False', _('disabled')),
|
||||
('True', _('enabled')),
|
||||
@@ -100,12 +67,9 @@ class SettingsForm(forms.Form):
|
||||
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
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.obj or self.locales:
|
||||
for k, field in self.fields.items():
|
||||
if isinstance(field, I18nFormField):
|
||||
field.widget.enabled_langcodes = self.obj.settings.get('locales') if self.obj else self.locales
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
|
||||
@@ -1,346 +1,16 @@
|
||||
import copy
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Model, QuerySet, TextField
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format, number_format
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
|
||||
class LazyI18nString:
|
||||
"""
|
||||
This represents an internationalized string that is/was/will be stored in the database.
|
||||
"""
|
||||
|
||||
def __init__(self, data: Optional[Union[str, Dict[str, str]]]):
|
||||
"""
|
||||
Creates a new i18n-aware string.
|
||||
|
||||
:param data: If this is a dictionary, it is expected to map language codes to translations.
|
||||
If this is a string that can be parsed as JSON, it will be parsed and used as such a dictionary.
|
||||
If this is anything else, it will be cast to a string and used for all languages.
|
||||
"""
|
||||
self.data = data
|
||||
if isinstance(self.data, str) and self.data is not None:
|
||||
try:
|
||||
j = json.loads(self.data)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
self.data = j
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Evaluate the given string with respect to the currently active locale.
|
||||
|
||||
If no string is available in the currently active language, this will give you
|
||||
the string in the system's default language. If this is unavailable as well, it
|
||||
will give you the string in the first language available.
|
||||
"""
|
||||
return self.localize(translation.get_language() or settings.LANGUAGE_CODE)
|
||||
|
||||
def __bool__(self):
|
||||
if not self.data:
|
||||
return False
|
||||
if isinstance(self.data, dict):
|
||||
return any(self.data.values())
|
||||
return True
|
||||
|
||||
def localize(self, lng: str) -> str:
|
||||
"""
|
||||
Evaluate the given string with respect to the locale defined by ``lng``.
|
||||
|
||||
If no string is available in the currently active language, this will give you
|
||||
the string in the system's default language. If this is unavailable as well, it
|
||||
will give you the string in the first language available.
|
||||
|
||||
:param lng: A locale code, e.g. ``de``. If you specify a code including a country
|
||||
or region like ``de-AT``, exact matches will be used preferably, but if only
|
||||
a ``de`` or ``de-AT`` translation exists, this might be returned as well.
|
||||
"""
|
||||
if self.data is None:
|
||||
return ""
|
||||
|
||||
if isinstance(self.data, dict):
|
||||
firstpart = lng.split('-')[0]
|
||||
similar = [l for l in self.data.keys() if (l.startswith(firstpart + "-") or firstpart == l) and l != lng]
|
||||
if self.data.get(lng):
|
||||
return self.data[lng]
|
||||
elif self.data.get(firstpart):
|
||||
return self.data[firstpart]
|
||||
elif similar and any([self.data.get(s) for s in similar]):
|
||||
for s in similar:
|
||||
if self.data.get(s):
|
||||
return self.data.get(s)
|
||||
elif self.data.get(settings.LANGUAGE_CODE):
|
||||
return self.data[settings.LANGUAGE_CODE]
|
||||
elif len(self.data):
|
||||
return list(self.data.items())[0][1]
|
||||
else:
|
||||
return ""
|
||||
else:
|
||||
return str(self.data)
|
||||
|
||||
def __repr__(self) -> str: # NOQA
|
||||
return '<LazyI18nString: %s>' % repr(self.data)
|
||||
|
||||
def __lt__(self, other) -> bool: # NOQA
|
||||
return str(self) < str(other)
|
||||
|
||||
def __format__(self, format_spec):
|
||||
return self.__str__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
if hasattr(other, 'data'):
|
||||
return self.data == other.data
|
||||
return self.data == other
|
||||
|
||||
class LazyGettextProxy:
|
||||
def __init__(self, lazygettext):
|
||||
self.lazygettext = lazygettext
|
||||
|
||||
def __getitem__(self, item):
|
||||
with language(item):
|
||||
return str(ugettext(self.lazygettext))
|
||||
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def __str__(self):
|
||||
return str(ugettext(self.lazygettext))
|
||||
|
||||
@classmethod
|
||||
def from_gettext(cls, lazygettext):
|
||||
l = LazyI18nString({})
|
||||
l.data = cls.LazyGettextProxy(lazygettext)
|
||||
return l
|
||||
|
||||
|
||||
class I18nWidget(forms.MultiWidget):
|
||||
"""
|
||||
The default form widget for I18nCharField and I18nTextField. It makes
|
||||
use of Django's MultiWidget mechanism and does some magic to save you
|
||||
time.
|
||||
"""
|
||||
widget = forms.TextInput
|
||||
|
||||
def __init__(self, langcodes: List[str], field: forms.Field, attrs=None):
|
||||
widgets = []
|
||||
self.langcodes = langcodes
|
||||
self.enabled_langcodes = langcodes
|
||||
self.field = field
|
||||
for lng in self.langcodes:
|
||||
a = copy.copy(attrs) or {}
|
||||
a['lang'] = lng
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
data = []
|
||||
first_enabled = None
|
||||
any_filled = False
|
||||
any_enabled_filled = False
|
||||
if not isinstance(value, LazyI18nString):
|
||||
value = LazyI18nString(value)
|
||||
for i, lng in enumerate(self.langcodes):
|
||||
dataline = (
|
||||
value.data[lng]
|
||||
if value is not None and (
|
||||
isinstance(value.data, dict) or isinstance(value.data, LazyI18nString.LazyGettextProxy)
|
||||
) and lng in value.data
|
||||
else None
|
||||
)
|
||||
any_filled = any_filled or (lng in self.enabled_langcodes and dataline)
|
||||
if not first_enabled and lng in self.enabled_langcodes:
|
||||
first_enabled = i
|
||||
if dataline:
|
||||
any_enabled_filled = True
|
||||
data.append(dataline)
|
||||
if value and not isinstance(value.data, dict):
|
||||
data[first_enabled] = value.data
|
||||
elif value and not any_enabled_filled:
|
||||
data[first_enabled] = value.localize(self.enabled_langcodes[0])
|
||||
return data
|
||||
|
||||
def render(self, name, value, attrs=None):
|
||||
if self.is_localized:
|
||||
for widget in self.widgets:
|
||||
widget.is_localized = self.is_localized
|
||||
# value is a list of values, each corresponding to a widget
|
||||
# in self.widgets.
|
||||
if not isinstance(value, list):
|
||||
value = self.decompress(value)
|
||||
output = []
|
||||
final_attrs = self.build_attrs(attrs)
|
||||
id_ = final_attrs.get('id', None)
|
||||
for i, widget in enumerate(self.widgets):
|
||||
if self.langcodes[i] not in self.enabled_langcodes:
|
||||
continue
|
||||
try:
|
||||
widget_value = value[i]
|
||||
except IndexError:
|
||||
widget_value = None
|
||||
if id_:
|
||||
final_attrs = dict(
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.langcodes[i]
|
||||
)
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs))
|
||||
return mark_safe(self.format_output(output))
|
||||
|
||||
def format_output(self, rendered_widgets):
|
||||
return '<div class="i18n-form-group">%s</div>' % super().format_output(rendered_widgets)
|
||||
|
||||
|
||||
class I18nTextInput(I18nWidget):
|
||||
widget = forms.TextInput
|
||||
|
||||
|
||||
class I18nTextarea(I18nWidget):
|
||||
widget = forms.Textarea
|
||||
|
||||
|
||||
class I18nFormField(forms.MultiValueField):
|
||||
"""
|
||||
The form field that is used by I18nCharField and I18nTextField. It makes use
|
||||
of Django's MultiValueField mechanism to create one sub-field per available
|
||||
language.
|
||||
|
||||
It contains special treatment to make sure that a field marked as "required" is validated
|
||||
as "filled out correctly" if *at least one* translation is filled it. It is never required
|
||||
to fill in all of them. This has the drawback that the HTML property ``required`` is set on
|
||||
none of the fields as this would lead to irritating behaviour.
|
||||
|
||||
:param langcodes: An iterable of locale codes that the widget should render a field for. If
|
||||
omitted, fields will be rendered for all languages supported by pretix.
|
||||
"""
|
||||
|
||||
def compress(self, data_list):
|
||||
langcodes = self.langcodes
|
||||
data = {}
|
||||
for i, value in enumerate(data_list):
|
||||
data[langcodes[i]] = value
|
||||
return LazyI18nString(data)
|
||||
|
||||
def clean(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
# This happens e.g. if the field is disabled
|
||||
return value
|
||||
found = False
|
||||
clean_data = []
|
||||
errors = []
|
||||
for i, field in enumerate(self.fields):
|
||||
try:
|
||||
field_value = value[i]
|
||||
except IndexError:
|
||||
field_value = None
|
||||
if field_value not in self.empty_values:
|
||||
found = True
|
||||
try:
|
||||
clean_data.append(field.clean(field_value))
|
||||
except forms.ValidationError as e:
|
||||
# Collect all validation errors in a single list, which we'll
|
||||
# raise at the end of clean(), rather than raising a single
|
||||
# exception for the first error we encounter. Skip duplicates.
|
||||
errors.extend(m for m in e.error_list if m not in errors)
|
||||
if errors:
|
||||
raise forms.ValidationError(errors)
|
||||
if self.one_required and not found:
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
|
||||
out = self.compress(clean_data)
|
||||
self.validate(out)
|
||||
self.run_validators(out)
|
||||
return out
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = []
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
}
|
||||
self.langcodes = kwargs.pop('langcodes', [l[0] for l in settings.LANGUAGES])
|
||||
self.one_required = kwargs.get('required', True)
|
||||
kwargs['required'] = False
|
||||
kwargs['widget'] = kwargs['widget'](
|
||||
langcodes=self.langcodes, field=self, **kwargs.pop('widget_kwargs', {})
|
||||
)
|
||||
defaults.update(**kwargs)
|
||||
for lngcode in self.langcodes:
|
||||
defaults['label'] = '%s (%s)' % (defaults.get('label'), lngcode)
|
||||
fields.append(forms.CharField(**defaults))
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class I18nFieldMixin:
|
||||
form_class = I18nFormField
|
||||
widget = I18nTextInput
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
return value
|
||||
return LazyI18nString(value)
|
||||
|
||||
def get_prep_value(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
value = value.data
|
||||
if isinstance(value, dict):
|
||||
return json.dumps({k: v for k, v in value.items() if v}, sort_keys=True)
|
||||
return value
|
||||
|
||||
def get_prep_lookup(self, lookup_type, value): # NOQA
|
||||
raise TypeError('Lookups on i18n string currently not supported.')
|
||||
|
||||
def from_db_value(self, value, expression, connection, context):
|
||||
return LazyI18nString(value)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': self.form_class, 'widget': self.widget}
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
||||
class I18nCharField(I18nFieldMixin, TextField):
|
||||
"""
|
||||
A CharField which takes internationalized data. Internally, a TextField dabase
|
||||
field is used to store JSON. If you interact with this field, you will work
|
||||
with LazyI18nString instances.
|
||||
"""
|
||||
widget = I18nTextInput
|
||||
|
||||
|
||||
class I18nTextField(I18nFieldMixin, TextField):
|
||||
"""
|
||||
Like I18nCharField, but for TextFields.
|
||||
"""
|
||||
widget = I18nTextarea
|
||||
|
||||
|
||||
class I18nJSONEncoder(DjangoJSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, LazyI18nString):
|
||||
return obj.data
|
||||
elif isinstance(obj, QuerySet):
|
||||
return list(obj)
|
||||
elif isinstance(obj, Model):
|
||||
return {'type': obj.__class__.__name__, 'id': obj.id}
|
||||
else:
|
||||
return super().default(obj)
|
||||
from i18nfield.fields import ( # noqa
|
||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||
)
|
||||
from i18nfield.forms import I18nFormField # noqa
|
||||
# Compatibility imports
|
||||
from i18nfield.strings import LazyI18nString # noqa
|
||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||
|
||||
|
||||
class LazyDate:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from ...signals import periodic_task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild static files and language files"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
periodic_task.send(self)
|
||||
call_command('compilemessages', verbosity=1, interactive=False)
|
||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
||||
call_command('collectstatic', verbosity=1, interactive=False)
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.invoices
|
||||
import pretix.base.models.items
|
||||
import pretix.base.models.orders
|
||||
import pretix.base.models.vouchers
|
||||
|
||||
|
||||
def initial_user(apps, schema_editor):
|
||||
User = apps.get_model("pretixbase", "User")
|
||||
user = User(email='admin@localhost')
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.password = make_password('admin')
|
||||
user.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0001_initial'), ('pretixbase', '0002_auto_20160209_0940'), ('pretixbase', '0003_eventpermission_can_change_vouchers'), ('pretixbase', '0004_auto_20160209_1023'), ('pretixbase', '0005_auto_20160211_1459'), ('pretixbase', '0006_auto_20160211_1630'), ('pretixbase', '0007_auto_20160211_1710'), ('pretixbase', '0008_invoiceaddress'), ('pretixbase', '0009_auto_20160222_2002'), ('pretixbase', '0010_orderposition_secret'), ('pretixbase', '0011_auto_20160311_2052'), ('pretixbase', '0012_auto_20160312_1040'), ('pretixbase', '0013_invoice_locale'), ('pretixbase', '0014_invoice_additional_text'), ('pretixbase', '0015_auto_20160312_1924'), ('pretixbase', '0016_voucher_variation'), ('pretixbase', '0017_auto_20160324_1615'), ('pretixbase', '0018_auto_20160326_1104'), ('pretixbase', '0019_auto_20160326_1139'), ('pretixbase', '0020_auto_20160418_2106'), ('pretixbase', '0021_auto_20160418_2117'), ('pretixbase', '0020_auto_20160421_1943'), ('pretixbase', '0022_merge'), ('pretixbase', '0023_auto_20160601_1039'), ('pretixbase', '0024_auto_20160728_1725'), ('pretixbase', '0025_auto_20160802_2202'), ('pretixbase', '0026_order_comment'), ('pretixbase', '0027_auto_20160815_1254'), ('pretixbase', '0028_auto_20160816_1242')]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0006_require_contenttypes_0002'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')),
|
||||
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
|
||||
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
|
||||
('is_staff', models.BooleanField(default=False, verbose_name='Is site admin')),
|
||||
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Date joined')),
|
||||
('locale', models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)')], default='en', max_length=50, verbose_name='Language')),
|
||||
('timezone', models.CharField(default='UTC', max_length=100, verbose_name='Timezone')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=initial_user,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedFile',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('date', models.DateTimeField(blank=True, null=True)),
|
||||
('filename', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.base.cachedfile_name)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('cachedfile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CartPosition',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
|
||||
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
|
||||
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('cart_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Cart ID (e.g. session key)')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
|
||||
('expires', models.DateTimeField(verbose_name='Expiration date')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart position',
|
||||
'verbose_name_plural': 'Cart positions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')),
|
||||
('is_public', models.BooleanField(default=False, help_text="If selected, this event may show up on the ticket system's start page or an organization profile.", verbose_name='Visible in public lists')),
|
||||
('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')),
|
||||
('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')),
|
||||
('plugins', models.TextField(blank=True, null=True, verbose_name='Plugins')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Event',
|
||||
'ordering': ('date_from', 'name'),
|
||||
'verbose_name_plural': 'Events',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventLock',
|
||||
fields=[
|
||||
('event', models.CharField(max_length=36, primary_key=True, serialize=False)),
|
||||
('date', models.DateTimeField(auto_now=True)),
|
||||
('token', models.UUIDField(default=uuid.uuid4)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventPermission',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('can_change_settings', models.BooleanField(default=True, verbose_name='Can change event settings')),
|
||||
('can_change_items', models.BooleanField(default=True, verbose_name='Can change product settings')),
|
||||
('can_view_orders', models.BooleanField(default=True, verbose_name='Can view orders')),
|
||||
('can_change_permissions', models.BooleanField(default=True, verbose_name='Can change permissions')),
|
||||
('can_change_orders', models.BooleanField(default=True, verbose_name='Can change orders')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Event')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL)),
|
||||
('can_change_vouchers', models.BooleanField(default=True, verbose_name='Can change vouchers')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Event permission',
|
||||
'verbose_name_plural': 'Event permissions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.TextField()),
|
||||
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Event')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')),
|
||||
('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('picture', models.ImageField(blank=True, null=True, upload_to=pretix.base.models.items.itempicture_upload_to, verbose_name='Product picture')),
|
||||
('available_from', models.DateTimeField(blank=True, help_text='This product will not be sold before the given date.', null=True, verbose_name='Available from')),
|
||||
('available_until', models.DateTimeField(blank=True, help_text='This product will not be sold after the given date.', null=True, verbose_name='Available until')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product',
|
||||
'ordering': ('category__position', 'category', 'position'),
|
||||
'verbose_name_plural': 'Products',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemCategory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product category',
|
||||
'ordering': ('position', 'id'),
|
||||
'verbose_name_plural': 'Product categories',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('position', models.PositiveIntegerField(default=0, verbose_name='Position')),
|
||||
('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='pretixbase.Item')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product variation',
|
||||
'ordering': ('position', 'id'),
|
||||
'verbose_name_plural': 'Product variations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LogEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('data', models.TextField(default='{}')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=16, verbose_name='Order code')),
|
||||
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
|
||||
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
|
||||
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
|
||||
('datetime', models.DateTimeField(verbose_name='Date')),
|
||||
('expires', models.DateTimeField(verbose_name='Expiration date')),
|
||||
('payment_date', models.DateTimeField(blank=True, null=True, verbose_name='Payment date')),
|
||||
('payment_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
|
||||
('payment_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee')),
|
||||
('payment_info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
|
||||
('payment_manual', models.BooleanField(default=False, verbose_name='Payment state was manually modified')),
|
||||
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total amount')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='pretixbase.Event', verbose_name='Event')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Order',
|
||||
'ordering': ('-datetime',),
|
||||
'verbose_name_plural': 'Orders',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderPosition',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')),
|
||||
('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')),
|
||||
('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='positions', to='pretixbase.Order', verbose_name='Order')),
|
||||
('variation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Order position',
|
||||
'verbose_name_plural': 'Order positions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Organizer',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organizer',
|
||||
'ordering': ('name',),
|
||||
'verbose_name_plural': 'Organizers',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizerPermission',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('can_create_events', models.BooleanField(default=True, verbose_name='Can create events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Organizer')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organizer permission',
|
||||
'verbose_name_plural': 'Organizer permissions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizerSetting',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=255)),
|
||||
('value', models.TextField()),
|
||||
('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Question',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('question', i18nfield.fields.I18nTextField(verbose_name='Question')),
|
||||
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
|
||||
('required', models.BooleanField(default=False, verbose_name='Required question')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
|
||||
('items', models.ManyToManyField(blank=True, help_text='This question will be asked to buyers of the selected products', related_name='questions', to='pretixbase.Item', verbose_name='Products')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Question',
|
||||
'verbose_name_plural': 'Questions',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionAnswer',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', models.TextField()),
|
||||
('cartposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.CartPosition')),
|
||||
('orderposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.OrderPosition')),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.Question')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Quota',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||
('size', models.PositiveIntegerField(blank=True, help_text='Leave empty for an unlimited number of tickets.', null=True, verbose_name='Total capacity')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.Event', verbose_name='Event')),
|
||||
('items', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.Item', verbose_name='Item')),
|
||||
('variations', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.ItemVariation', verbose_name='Variations')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Quota',
|
||||
'verbose_name_plural': 'Quotas',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Voucher',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=255, verbose_name='Voucher code')),
|
||||
('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='Valid until')),
|
||||
('block_quota', models.BooleanField(default=False, help_text="If activated, this voucher will be substracted from the affected product's quotas, such that it is guaranteed that anyone with this voucher code does receive a ticket.", verbose_name='Reserve ticket from quota')),
|
||||
('allow_ignore_quota', models.BooleanField(default=False, help_text='If activated, a holder of this voucher code can buy tickets, even if there are none left.', verbose_name='Allow to bypass quota')),
|
||||
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Set product price to')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Event', verbose_name='Event')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Voucher',
|
||||
'verbose_name_plural': 'Vouchers',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizer',
|
||||
name='permitted',
|
||||
field=models.ManyToManyField(related_name='organizers', through='pretixbase.OrganizerPermission', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.Event', verbose_name='Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='permitted',
|
||||
field=models.ManyToManyField(related_name='events', through='pretixbase.EventPermission', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event', verbose_name='Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='variation',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='voucher',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='item',
|
||||
field=models.ForeignKey(blank=True, help_text="This product is added to the user's cart if the voucher is redeemed.", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Item', verbose_name='Product'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, help_text='If empty, the product will cost its normal price.', max_digits=10, null=True, verbose_name='Set product price to'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.BooleanField(default=False, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='voucher',
|
||||
unique_together=set([('event', 'code')]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='code',
|
||||
field=models.CharField(default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=7, verbose_name='Tax rate'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='tax_value',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Tax value'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7, verbose_name='Taxes included in percent'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceAddress',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_modified', models.DateTimeField(auto_now=True)),
|
||||
('company', models.CharField(blank=True, max_length=255, verbose_name='Company name')),
|
||||
('name', models.CharField(blank=True, max_length=255, verbose_name='Full name')),
|
||||
('street', models.TextField(verbose_name='Address')),
|
||||
('zipcode', models.CharField(max_length=30, verbose_name='ZIP code')),
|
||||
('city', models.CharField(max_length=255, verbose_name='City')),
|
||||
('country', models.CharField(max_length=255, verbose_name='Country')),
|
||||
('vat_id', models.CharField(blank=True, max_length=255, verbose_name='VAT ID')),
|
||||
('order', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoice_address', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='live',
|
||||
field=models.BooleanField(default=False, verbose_name='Shop is live'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='payment_fee_tax_rate',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax rate'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='payment_fee_tax_value',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee tax'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invoice_no', models.PositiveIntegerField(db_index=True)),
|
||||
('is_cancelled', models.BooleanField(default=False)),
|
||||
('invoice_from', models.TextField()),
|
||||
('invoice_to', models.TextField()),
|
||||
('date', models.DateField(default=datetime.date.today)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.invoices.invoice_filename)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceLine',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.TextField()),
|
||||
('gross_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('tax_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7)),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='pretixbase.Invoice')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='locale',
|
||||
field=models.CharField(default='en', max_length=50),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='additional_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='is_cancelled',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='is_cancellation',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='refers',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refered', to='pretixbase.Invoice'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('event', 'invoice_no')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='variation',
|
||||
field=models.ForeignKey(blank=True, help_text='This variation of the product select above is being used.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.ItemVariation', verbose_name='Product variation'),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='logentry',
|
||||
options={'ordering': ('-datetime',)},
|
||||
),
|
||||
migrations.AddField(
|
||||
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.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionOption',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'), ('C', 'Choose one from a list'), ('M', 'Choose multiple from a list')], max_length=5, verbose_name='Question type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionoption',
|
||||
name='question',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='pretixbase.Question'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='questionanswer',
|
||||
name='options',
|
||||
field=models.ManyToManyField(blank=True, related_name='answers', to='pretixbase.QuestionOption'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='quota',
|
||||
field=models.ForeignKey(blank=True, help_text='If enabled, the voucher is valid for any product affected by this quota.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quota', to='pretixbase.Quota', verbose_name='Quota'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='tag',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='You can use this field to group multiple vouchers together. If you enter the same value for multiple vouchers, you can get statistics on how many of them have been redeemed etc.', max_length=255, verbose_name='Tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='hide_without_voucher',
|
||||
field=models.BooleanField(default=False, help_text='This product will be hidden from the event page until the user enters a voucher code that is specifically tied to this product (and not via a quota).', verbose_name='This product will only be shown if a voucher matching the product is redeemed.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='require_voucher',
|
||||
field=models.BooleanField(default=False, help_text='To buy this product, the user needs a voucher that applies to this product either directly or via a quota.', verbose_name='This product can only be bought using a voucher.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='logentry',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='logentry',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(db_index=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='code',
|
||||
field=models.CharField(db_index=True, max_length=16, verbose_name='Order code'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='code',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.vouchers.generate_code, max_length=255, verbose_name='Voucher code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, help_text='The text entered in this field will not be visible to the user and is available for your convenience.', verbose_name='Comment'),
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cartposition',
|
||||
name='base_price',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cartposition',
|
||||
name='voucher_discount',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderposition',
|
||||
name='base_price',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='orderposition',
|
||||
name='voucher_discount',
|
||||
),
|
||||
]
|
||||
@@ -7,10 +7,10 @@ from decimal import Decimal
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.items
|
||||
import pretix.base.models.orders
|
||||
@@ -64,7 +64,7 @@ class Migration(migrations.Migration):
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')),
|
||||
('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')),
|
||||
('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')),
|
||||
('date_from', models.DateTimeField(verbose_name='Event start time')),
|
||||
@@ -119,9 +119,9 @@ class Migration(migrations.Migration):
|
||||
name='Item',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Item name')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('description', pretix.base.i18n.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('description', i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')),
|
||||
('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')),
|
||||
('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')),
|
||||
@@ -141,7 +141,7 @@ class Migration(migrations.Migration):
|
||||
name='ItemCategory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('name', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Category name')),
|
||||
('position', models.IntegerField(default=0)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')),
|
||||
],
|
||||
@@ -156,7 +156,7 @@ class Migration(migrations.Migration):
|
||||
name='ItemVariation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('value', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('value', i18nfield.fields.I18nCharField(max_length=255, verbose_name='Description')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('position', models.PositiveIntegerField(default=0, verbose_name='Position')),
|
||||
('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')),
|
||||
@@ -264,7 +264,7 @@ class Migration(migrations.Migration):
|
||||
name='Question',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('question', pretix.base.i18n.I18nTextField(verbose_name='Question')),
|
||||
('question', i18nfield.fields.I18nTextField(verbose_name='Question')),
|
||||
('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')),
|
||||
('required', models.BooleanField(default=False, verbose_name='Required question')),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')),
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -19,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
name='QuestionOption',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('answer', pretix.base.i18n.I18nCharField(verbose_name='Answer')),
|
||||
('answer', i18nfield.fields.I18nCharField(verbose_name='Answer')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
# Generated by Django 1.9.4 on 2016-04-21 19:43
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.i18n
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -17,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='description',
|
||||
field=pretix.base.i18n.I18nTextField(blank=True, verbose_name='Category description'),
|
||||
field=i18nfield.fields.I18nTextField(blank=True, verbose_name='Category description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='questionanswer',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def preserve_event_settings(apps, schema_editor):
|
||||
Event = apps.get_model('pretixbase', 'Event')
|
||||
EventSetting = apps.get_model('pretixbase', 'EventSetting')
|
||||
for e in Event.objects.all():
|
||||
EventSetting.objects.create(object=e, key='mail_days_order_expire_warning', value='0')
|
||||
|
||||
|
||||
def forwards42(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
EventSetting = apps.get_model('pretixbase', 'EventSetting')
|
||||
etz = {
|
||||
s['object_id']: s['value']
|
||||
for s in EventSetting.objects.filter(key='timezone').values('object_id', 'value')
|
||||
}
|
||||
for order in Order.objects.all():
|
||||
tz = pytz.timezone(etz.get(order.event_id, 'UTC'))
|
||||
order.expires = order.expires.astimezone(tz).replace(hour=23, minute=59, second=59)
|
||||
order.save()
|
||||
|
||||
|
||||
def forwards44(apps, schema_editor):
|
||||
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
|
||||
CachedTicket.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0031_auto_20160816_0648'), ('pretixbase', '0032_question_position'), ('pretixbase', '0033_auto_20160821_2222'), ('pretixbase', '0034_auto_20160830_1952'), ('pretixbase', '0032_item_allow_cancel'), ('pretixbase', '0033_auto_20160822_1044'), ('pretixbase', '0035_merge'), ('pretixbase', '0036_auto_20160902_0755'), ('pretixbase', '0037_invoice_payment_provider_text'), ('pretixbase', '0038_auto_20160924_1448'), ('pretixbase', '0039_user_require_2fa'), ('pretixbase', '0040_u2fdevice'), ('pretixbase', '0041_auto_20161018_1654'), ('pretixbase', '0042_order_expires'), ('pretixbase', '0043_globalsetting'), ('pretixbase', '0044_auto_20161101_1610'), ('pretixbase', '0045_auto_20161108_1542'), ('pretixbase', '0046_order_meta_info'), ('pretixbase', '0047_auto_20161126_1300'), ('pretixbase', '0048_auto_20161129_1330')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0030_auto_20160816_0646'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='invoice',
|
||||
old_name='invoice_no_charfield',
|
||||
new_name='invoice_no',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='footer_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='introductory_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('event', 'invoice_no')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='position',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='question',
|
||||
options={'ordering': ('position', 'id'), 'verbose_name': 'Question', 'verbose_name_plural': 'Questions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be cancelled by the user. It may still be cancelled by you.', verbose_name='Allow product to be cancelled'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='expiry_reminder_sent',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=preserve_event_settings,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='payment_provider_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='can_view_vouchers',
|
||||
field=models.BooleanField(default=True, verbose_name='Can view vouchers'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If you deactivate this, an order including this product might not be canceled by the user. It may still be canceled by you.', verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'canceled'), ('r', 'refunded')], db_index=True, max_length=3, verbose_name='Status'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='require_2fa',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='U2FDevice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)),
|
||||
('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')),
|
||||
('json_data', models.TextField()),
|
||||
('user', models.ForeignKey(help_text='The user that this device belongs to.', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedticket',
|
||||
name='cachedfile',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards42,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSetting',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards44,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cachedticket',
|
||||
name='order',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='order_position',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.OrderPosition'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='expires',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Expiration date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='valid_until',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Valid until'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='meta_info',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Meta information'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='max_usages',
|
||||
field=models.PositiveIntegerField(default=1, help_text='Number of times this voucher can be redeemed.', verbose_name='Maximum usages'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='redeemed',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Redeemed'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='voucher',
|
||||
old_name='price',
|
||||
new_name='value',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='value',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-03 14:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
import django.db.migrations.operations.special
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import i18nfield.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.event
|
||||
import pretix.base.models.orders
|
||||
import pretix.base.models.organizer
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
def forwards50(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
for o in Order.objects.all():
|
||||
for i, p in enumerate(o.positions.all()):
|
||||
p.positionid = i + 1
|
||||
p.save()
|
||||
|
||||
|
||||
def invalidate_ticket_cache(apps, schema_editor):
|
||||
CachedTicket = apps.get_model('pretixbase', 'CachedTicket')
|
||||
for ct in CachedTicket.objects.all():
|
||||
try:
|
||||
if ct.cachedfile:
|
||||
ct.cachedfile.delete()
|
||||
if ct.cachedfile.file:
|
||||
ct.cachedfile.file.delete(False)
|
||||
except models.Model.DoesNotExist:
|
||||
pass
|
||||
ct.delete()
|
||||
|
||||
|
||||
def merge_names(apps, schema_editor):
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.givenname:
|
||||
if u.familyname:
|
||||
u.fullname = u.givenname + " " + u.familyname
|
||||
else:
|
||||
u.fullname = u.givenname
|
||||
elif u.familyname:
|
||||
u.fullname = u.familyname
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('pretixbase', '0050_orderposition_positionid'), ('pretixbase', '0051_auto_20161221_1720'), ('pretixbase', '0052_auto_20161231_1533'), ('pretixbase', '0053_auto_20170104_1252'), ('pretixbase', '0054_auto_20170107_1058'), ('pretixbase', '0055_organizerpermission_can_change_permissions'), ('pretixbase', '0056_auto_20170107_1251'), ('pretixbase', '0057_auto_20170107_1531'), ('pretixbase', '0058_auto_20170107_1533'), ('pretixbase', '0059_cachedcombinedticket'), ('pretixbase', '0060_auto_20170113_1438'), ('pretixbase', '0061_event_location')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0049_checkin'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='positionid',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=forwards50,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=invalidate_ticket_cache,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='cachedticket',
|
||||
name='cachedfile',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='extension',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedticket_name),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='checkin',
|
||||
name='position',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkins', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
name='price_mode',
|
||||
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='none', max_length=100, verbose_name='Price mode'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RequiredAction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('done', models.BooleanField(default=False)),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('data', models.TextField(default='{}')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime',),
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This will be used in order codes, invoice numbers, links and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='event',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='requiredaction',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.event.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='can_change_permissions',
|
||||
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_email',
|
||||
field=models.EmailField(blank=True, max_length=254, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organizerpermission',
|
||||
name='invite_token',
|
||||
field=models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizerpermission',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='fullname',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Full name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer',
|
||||
name='slug',
|
||||
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=merge_names,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='familyname',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='givenname',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CachedCombinedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('extension', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
|
||||
('created', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
]
|
||||
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
42
src/pretix/base/migrations/0051_auto_20170206_2027.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-02-06 20:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WaitingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
|
||||
('locale', models.CharField(default='en', max_length=190)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
|
||||
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
|
||||
('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')),
|
||||
('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created'],
|
||||
'verbose_name': 'Waiting list entry',
|
||||
'verbose_name_plural': 'Waiting list entries',
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.validators
|
||||
|
||||
|
||||
|
||||
29
src/pretix/base/migrations/0059_cachedcombinedticket.py
Normal file
29
src/pretix/base/migrations/0059_cachedcombinedticket.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-13 14:07
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.orders
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0058_auto_20170107_1533'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CachedCombinedTicket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('provider', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=255)),
|
||||
('extension', models.CharField(max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.orders.cachedcombinedticket_name)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
]
|
||||
28
src/pretix/base/migrations/0060_auto_20170113_1438.py
Normal file
28
src/pretix/base/migrations/0060_auto_20170113_1438.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.4 on 2017-01-13 14:38
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0059_cachedcombinedticket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cachedcombinedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cachedticket',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
21
src/pretix/base/migrations/0061_event_location.py
Normal file
21
src/pretix/base/migrations/0061_event_location.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.5 on 2017-02-01 04:31
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0060_auto_20170113_1438'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='location',
|
||||
field=i18nfield.fields.I18nCharField(blank=True, max_length=200, null=True, verbose_name='Location'),
|
||||
),
|
||||
]
|
||||
@@ -12,8 +12,11 @@ from .items import (
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .orders import (
|
||||
AbstractPosition, CachedTicket, CartPosition, InvoiceAddress, Order,
|
||||
OrderPosition, QuestionAnswer, generate_position_secret, generate_secret,
|
||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
|
||||
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
||||
generate_secret,
|
||||
)
|
||||
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from pretix.base.i18n import I18nJSONEncoder
|
||||
from i18nfield.utils import I18nJSONEncoder
|
||||
|
||||
|
||||
def cachedfile_name(instance, filename: str) -> str:
|
||||
@@ -60,7 +59,6 @@ class LoggingMixin:
|
||||
|
||||
|
||||
class LoggedModel(models.Model, LoggingMixin):
|
||||
logentries = GenericRelation('pretixbase.LogEntry')
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -71,4 +69,8 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
|
||||
:return: A QuerySet of LogEntry objects
|
||||
"""
|
||||
return self.logentries.all().select_related('user', 'event')
|
||||
from .log import LogEntry
|
||||
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
).select_related('user', 'event')
|
||||
|
||||
@@ -14,9 +14,9 @@ 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.i18n import I18nCharField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
@@ -51,6 +51,8 @@ class Event(LoggedModel):
|
||||
:type presale_start: datetime
|
||||
:param presale_end: No tickets will be sold after this date.
|
||||
:type presale_end: datetime
|
||||
:param location: venue
|
||||
:type location: str
|
||||
:param plugins: A comma-separated list of plugin names that are active for this
|
||||
event.
|
||||
:type plugins: str
|
||||
@@ -99,6 +101,11 @@ class Event(LoggedModel):
|
||||
verbose_name=_("Start of presale"),
|
||||
help_text=_("No products will be sold before this date."),
|
||||
)
|
||||
location = I18nCharField(
|
||||
null=True, blank=True,
|
||||
max_length=200,
|
||||
verbose_name=_("Location"),
|
||||
)
|
||||
plugins = models.TextField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Plugins"),
|
||||
|
||||
@@ -10,8 +10,9 @@ from django.db.models import F, Func, Q, Sum
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField, I18nTextField
|
||||
|
||||
from pretix.base.i18n import I18nCharField, I18nTextField
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
|
||||
from .event import Event
|
||||
@@ -140,6 +141,9 @@ class Item(LoggedModel):
|
||||
)
|
||||
default_price = models.DecimalField(
|
||||
verbose_name=_("Default price"),
|
||||
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, decimal_places=2, null=True
|
||||
)
|
||||
free_price = models.BooleanField(
|
||||
@@ -195,8 +199,9 @@ class Item(LoggedModel):
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
help_text=_('If you deactivate this, an order including this product might not be canceled by the user. '
|
||||
'It may still be canceled by you.')
|
||||
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')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -217,6 +222,11 @@ class Item(LoggedModel):
|
||||
if self.event:
|
||||
self.event.get_cache().clear()
|
||||
|
||||
@property
|
||||
def default_price_net(self):
|
||||
tax_value = round_decimal(self.default_price * (1 - 100 / (100 + self.tax_rate)))
|
||||
return self.default_price - tax_value
|
||||
|
||||
def is_available(self, now_dt: datetime=None) -> bool:
|
||||
"""
|
||||
Returns whether this item is available according to its ``active`` flag
|
||||
@@ -231,7 +241,7 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None):
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None):
|
||||
"""
|
||||
This method is used to determine whether this Item is currently available
|
||||
for sale.
|
||||
@@ -253,7 +263,7 @@ class Item(LoggedModel):
|
||||
if self.variations.count() > 0: # NOQA
|
||||
raise ValueError('Do not call this directly on items which have variations '
|
||||
'but call this on their ItemVariation objects')
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
@cached_property
|
||||
@@ -305,6 +315,15 @@ class ItemVariation(models.Model):
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
@property
|
||||
def price(self):
|
||||
return self.default_price if self.default_price is not None else self.item.default_price
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
tax_value = round_decimal(self.price * (1 - 100 / (100 + self.item.tax_rate)))
|
||||
return self.price - tax_value
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
if self.item:
|
||||
@@ -315,7 +334,7 @@ class ItemVariation(models.Model):
|
||||
if self.item:
|
||||
self.item.event.get_cache().clear()
|
||||
|
||||
def check_quotas(self, ignored_quotas=None, _cache=None) -> Tuple[int, int]:
|
||||
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether this ItemVariation is currently
|
||||
available for sale in terms of quotas.
|
||||
@@ -324,6 +343,7 @@ class ItemVariation(models.Model):
|
||||
quotas will be ignored in the calculation. If this leads
|
||||
to no quotas being checked at all, this method will return
|
||||
unlimited availability.
|
||||
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
|
||||
:returns: any of the return codes of :py:meth:`Quota.availability()`.
|
||||
"""
|
||||
check_quotas = set(self.quotas.all())
|
||||
@@ -331,7 +351,7 @@ class ItemVariation(models.Model):
|
||||
check_quotas -= set(ignored_quotas)
|
||||
if not check_quotas:
|
||||
return Quota.AVAILABILITY_OK, sys.maxsize
|
||||
return min([q.availability(_cache=_cache) for q in check_quotas],
|
||||
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def __lt__(self, other):
|
||||
@@ -342,9 +362,10 @@ class ItemVariation(models.Model):
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket
|
||||
by custom information, e.g. "Attendee age". A question can allow one of several
|
||||
input types, currently:
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
||||
several input types, currently:
|
||||
|
||||
* a number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -534,7 +555,7 @@ class Quota(LoggedModel):
|
||||
if self.event:
|
||||
self.event.get_cache().clear()
|
||||
|
||||
def availability(self, now_dt: datetime=None, _cache=None) -> Tuple[int, int]:
|
||||
def availability(self, now_dt: datetime=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]:
|
||||
"""
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
@@ -542,14 +563,18 @@ class Quota(LoggedModel):
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
if _cache and count_waitinglist is not _cache.get('_count_waitinglist', True):
|
||||
_cache.clear()
|
||||
|
||||
if _cache is not None and self.pk in _cache:
|
||||
return _cache[self.pk]
|
||||
res = self._availability(now_dt)
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
if _cache is not None:
|
||||
_cache[self.pk] = res
|
||||
_cache['_count_waitinglist'] = count_waitinglist
|
||||
return res
|
||||
|
||||
def _availability(self, now_dt: datetime=None):
|
||||
def _availability(self, now_dt: datetime=None, count_waitinglist=True):
|
||||
now_dt = now_dt or now()
|
||||
size_left = self.size
|
||||
if size_left is None:
|
||||
@@ -572,6 +597,11 @@ class Quota(LoggedModel):
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
def count_blocking_vouchers(self, now_dt: datetime=None) -> int:
|
||||
@@ -591,6 +621,13 @@ class Quota(LoggedModel):
|
||||
free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func))
|
||||
)['free'] or 0
|
||||
|
||||
def count_waiting_list_pending(self) -> int:
|
||||
from pretix.base.models import WaitingListEntry
|
||||
return WaitingListEntry.objects.filter(
|
||||
Q(voucher__isnull=True) &
|
||||
self._position_lookup
|
||||
).distinct().count()
|
||||
|
||||
def count_in_cart(self, now_dt: datetime=None) -> int:
|
||||
from pretix.base.models import CartPosition
|
||||
|
||||
|
||||
@@ -130,3 +130,6 @@ class LogEntry(models.Model):
|
||||
@cached_property
|
||||
def parsed_data(self):
|
||||
return json.loads(self.data)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
raise TypeError("Logs cannot be deleted.")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import copy
|
||||
import os
|
||||
import string
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -6,10 +7,11 @@ from typing import List, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.db.models import F, Sum
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -189,6 +191,10 @@ class Order(LoggedModel):
|
||||
"""
|
||||
return '{event}-{code}'.format(event=self.event.slug.upper(), code=self.code)
|
||||
|
||||
@property
|
||||
def changable(self):
|
||||
return self.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.code:
|
||||
self.assign_code()
|
||||
@@ -210,6 +216,18 @@ class Order(LoggedModel):
|
||||
else:
|
||||
self.payment_fee_tax_value = Decimal('0.00')
|
||||
|
||||
@property
|
||||
def payment_fee_net(self):
|
||||
return self.payment_fee - self.payment_fee_tax_value
|
||||
|
||||
@cached_property
|
||||
def tax_total(self):
|
||||
return (self.positions.aggregate(s=Sum('tax_value'))['s'] or 0) + self.payment_fee_tax_value
|
||||
|
||||
@property
|
||||
def net_total(self):
|
||||
return self.total - self.tax_total
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -228,7 +246,7 @@ class Order(LoggedModel):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset)
|
||||
if not Order.objects.filter(event=self.event, code=code).exists():
|
||||
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
|
||||
self.code = code
|
||||
return
|
||||
|
||||
@@ -268,12 +286,16 @@ class Order(LoggedModel):
|
||||
|
||||
def _can_be_paid(self) -> Union[bool, str]:
|
||||
error_messages = {
|
||||
'late': _("The payment is too late to be accepted."),
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||
"payments should be accepted in the payment settings."),
|
||||
}
|
||||
|
||||
if self.event.settings.get('payment_term_last'):
|
||||
if now() > self.event.payment_term_last:
|
||||
return error_messages['late']
|
||||
return error_messages['late_lastdate']
|
||||
|
||||
if self.status == self.STATUS_PENDING:
|
||||
return True
|
||||
if not self.event.settings.get('payment_term_accept_late'):
|
||||
@@ -423,6 +445,10 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
q.answer = ""
|
||||
|
||||
@property
|
||||
def net_price(self):
|
||||
return self.price - self.tax_value
|
||||
|
||||
|
||||
class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -565,12 +591,24 @@ class InvoiceAddress(models.Model):
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
return 'tickets/{org}/{ev}/{code}-{no}-{prov}-{secret}.pdf'.format(
|
||||
return 'tickets/{org}/{ev}/{code}-{no}-{prov}-{secret}.dat'.format(
|
||||
org=instance.order_position.order.event.organizer.slug,
|
||||
ev=instance.order_position.order.event.slug,
|
||||
prov=instance.provider,
|
||||
no=instance.order_position.positionid,
|
||||
code=instance.order_position.order.code,
|
||||
secret=secret,
|
||||
ext=os.path.splitext(filename)[1]
|
||||
)
|
||||
|
||||
|
||||
def cachedcombinedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
return 'tickets/{org}/{ev}/{code}-{prov}-{secret}.dat'.format(
|
||||
org=instance.order.event.organizer.slug,
|
||||
ev=instance.order.event.slug,
|
||||
prov=instance.provider,
|
||||
code=instance.order.code,
|
||||
secret=secret
|
||||
)
|
||||
|
||||
@@ -581,6 +619,16 @@ class CachedTicket(models.Model):
|
||||
type = models.CharField(max_length=255)
|
||||
extension = models.CharField(max_length=255)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedticket_name)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class CachedCombinedTicket(models.Model):
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE)
|
||||
provider = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=255)
|
||||
extension = models.CharField(max_length=255)
|
||||
file = models.FileField(null=True, blank=True, upload_to=cachedcombinedticket_name)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedTicket)
|
||||
|
||||
@@ -210,11 +210,11 @@ class Voucher(LoggedModel):
|
||||
Returns whether this voucher applies to a given item (and optionally
|
||||
a variation).
|
||||
"""
|
||||
if self.quota:
|
||||
return item.quotas.filter(pk=self.quota.pk).exists()
|
||||
if self.item and not self.variation:
|
||||
return self.item == item
|
||||
return (self.item == item) and (self.variation == variation)
|
||||
if self.quota_id:
|
||||
return item.quotas.filter(pk=self.quota_id).exists()
|
||||
if self.item_id and not self.variation_id:
|
||||
return self.item_id == item.pk
|
||||
return (self.item_id == item.pk) and (variation and self.variation_id == variation.pk)
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
|
||||
130
src/pretix/base/models/waitinglist.py
Normal file
130
src/pretix/base/models/waitinglist.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Voucher
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
from .base import LoggedModel
|
||||
from .event import Event
|
||||
from .items import Item, ItemVariation
|
||||
|
||||
|
||||
class WaitingListException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WaitingListEntry(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="waitinglistentries",
|
||||
verbose_name=_("Event"),
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
verbose_name=_("On waiting list since"),
|
||||
auto_now_add=True
|
||||
)
|
||||
email = models.EmailField(
|
||||
verbose_name=_("E-mail address")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher',
|
||||
verbose_name=_("Assigned voucher"),
|
||||
null=True, blank=True
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item, related_name='waitinglistentries',
|
||||
verbose_name=_("Product"),
|
||||
help_text=_(
|
||||
"The product the user waits for."
|
||||
)
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
ItemVariation, related_name='waitinglistentries',
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Product variation"),
|
||||
help_text=_(
|
||||
"The variation of the product selected above."
|
||||
)
|
||||
)
|
||||
locale = models.CharField(
|
||||
max_length=190,
|
||||
default='en'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Waiting list entry")
|
||||
verbose_name_plural = _("Waiting list entries")
|
||||
ordering = ['created']
|
||||
|
||||
def __str__(self):
|
||||
return '%s waits for %s' % (str(self.email), str(self.item))
|
||||
|
||||
def clean(self):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=self.item, variation=self.variation, email=self.email, voucher__isnull=True
|
||||
).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
if not self.variation and self.item.has_variations:
|
||||
raise ValidationError(_('Please select a specific variation of this product.'))
|
||||
|
||||
def send_voucher(self, quota_cache=None, user=None):
|
||||
availability = (
|
||||
self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if self.variation
|
||||
else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] < 1:
|
||||
raise WaitingListException(_('This product is currently not available.'))
|
||||
if self.voucher:
|
||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||
|
||||
with transaction.atomic():
|
||||
v = Voucher.objects.create(
|
||||
event=self.event,
|
||||
max_usages=1,
|
||||
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
|
||||
item=self.item,
|
||||
variation=self.variation,
|
||||
tag='waiting-list',
|
||||
comment=_('Automatically created from waiting list entry for {email}').format(
|
||||
email=self.email
|
||||
),
|
||||
block_quota=True,
|
||||
)
|
||||
v.log_action('pretix.voucher.added.waitinglist', {
|
||||
'item': self.item.pk,
|
||||
'variation': self.variation.pk if self.variation else None,
|
||||
'tag': 'waiting-list',
|
||||
'block_quota': True,
|
||||
'valid_until': v.valid_until.isoformat(),
|
||||
'max_usages': 1,
|
||||
'email': self.email,
|
||||
'waitinglistentry': self.pk
|
||||
}, user=user)
|
||||
self.log_action('pretix.waitinglist.voucher', user=user)
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
with language(self.locale):
|
||||
mail(
|
||||
self.email,
|
||||
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
{
|
||||
'event': self.event.name,
|
||||
'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code,
|
||||
'code': self.voucher.code,
|
||||
'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''),
|
||||
'hours': self.event.settings.waiting_list_hours,
|
||||
},
|
||||
self.event,
|
||||
locale=self.locale
|
||||
)
|
||||
@@ -12,15 +12,25 @@ from django.http import HttpRequest
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea, LazyI18nString
|
||||
from pretix.base.models import Event, Order, Quota
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.presale.views import get_cart_total
|
||||
|
||||
|
||||
class PaymentProviderForm(Form):
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
for k, v in self.fields.items():
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
|
||||
|
||||
class BasePaymentProvider:
|
||||
"""
|
||||
This is the base class for all payment providers.
|
||||
@@ -187,8 +197,12 @@ class BasePaymentProvider:
|
||||
process. The default implementation constructs the form using
|
||||
:py:attr:`checkout_form_fields` and sets appropriate prefixes for the form
|
||||
and all fields and fills the form with data form the user's session.
|
||||
|
||||
If you overwrite this, we strongly suggest that you inherit from
|
||||
``PaymentProviderForm`` (from this module) that handles some nasty issues about
|
||||
required fields for you.
|
||||
"""
|
||||
form = Form(
|
||||
form = PaymentProviderForm(
|
||||
data=(request.POST if request.method == 'POST' else None),
|
||||
prefix='payment_%s' % self.identifier,
|
||||
initial={
|
||||
@@ -198,6 +212,12 @@ class BasePaymentProvider:
|
||||
}
|
||||
)
|
||||
form.fields = self.payment_form_fields
|
||||
|
||||
for k, v in form.fields.items():
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
|
||||
return form
|
||||
|
||||
def _is_still_available(self, now_dt=None):
|
||||
@@ -318,9 +338,7 @@ class BasePaymentProvider:
|
||||
The default implementation just returns ``None`` and therefore leaves the
|
||||
order unpaid. The user will be redirected to the order's detail page by default.
|
||||
|
||||
On errors, you should use Django's message framework to display an error message
|
||||
to the user.
|
||||
|
||||
On errors, you should raise a ``PaymentException``.
|
||||
:param order: The order object
|
||||
"""
|
||||
return None
|
||||
@@ -464,6 +482,10 @@ class BasePaymentProvider:
|
||||
'back to the buyer manually.'))
|
||||
|
||||
|
||||
class PaymentException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FreeOrderProvider(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
@@ -492,7 +514,7 @@ class FreeOrderProvider(BasePaymentProvider):
|
||||
try:
|
||||
mark_order_paid(order, 'free', send_mail=False)
|
||||
except Quota.QuotaExceededException as e:
|
||||
messages.error(request, str(e))
|
||||
raise PaymentException(str(e))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
from datetime import datetime, timedelta
|
||||
from collections import Counter, namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
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.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Quota, Voucher,
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
@@ -43,177 +47,307 @@ error_messages = {
|
||||
}
|
||||
|
||||
|
||||
def _extend_existing(event: Event, cart_id: str, expiry: datetime, now_dt: datetime) -> None:
|
||||
# Extend this user's cart session to 30 minutes from now to ensure all items in the
|
||||
# cart expire at the same time
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
CartPosition.objects.filter(
|
||||
Q(cart_id=cart_id) & Q(event=event) & Q(expires__gt=now_dt)
|
||||
).update(expires=expiry)
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
order = {
|
||||
RemoveOperation: 10,
|
||||
ExtendOperation: 20,
|
||||
AddOperation: 30
|
||||
}
|
||||
|
||||
def __init__(self, event: Event, cart_id: str):
|
||||
self.event = event
|
||||
self.cart_id = cart_id
|
||||
self.now_dt = now()
|
||||
self._operations = []
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
self._items_cache = {}
|
||||
self._variations_cache = {}
|
||||
self._expiry = None
|
||||
|
||||
def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str, now_dt: datetime) -> List[CartPosition]:
|
||||
positions = set()
|
||||
# For items that are already expired, we have to delete and re-add them, as they might
|
||||
# be no longer available or prices might have changed. Sorry!
|
||||
expired = CartPosition.objects.filter(
|
||||
Q(cart_id=cart_id) & Q(event=event) & Q(expires__lte=now_dt)
|
||||
)
|
||||
for cp in expired:
|
||||
items.insert(0, {
|
||||
'item': cp.item_id,
|
||||
'variation': cp.variation_id,
|
||||
'count': 1,
|
||||
'price': cp.price,
|
||||
'cp': cp,
|
||||
'voucher': cp.voucher.code if cp.voucher else None
|
||||
})
|
||||
positions.add(cp)
|
||||
return positions
|
||||
@property
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
)
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
|
||||
def _delete_expired(expired: List[CartPosition], now_dt: datetime) -> None:
|
||||
for cp in expired:
|
||||
if cp.expires <= now_dt:
|
||||
cp.delete()
|
||||
def _check_presale_dates(self):
|
||||
if self.event.presale_start and self.now_dt < self.event.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if self.event.presale_end and self.now_dt > self.event.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
|
||||
def _extend_expiry_of_valid_existing_positions(self):
|
||||
# Extend this user's cart session to ensure all items in the cart expire at the same time
|
||||
# We can extend the reservation of items which are not yet expired without risk
|
||||
self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry)
|
||||
|
||||
def _check_date(event: Event, now_dt: datetime) -> None:
|
||||
if event.presale_start and now_dt < event.presale_start:
|
||||
raise CartError(error_messages['not_started'])
|
||||
if event.presale_end and now_dt > event.presale_end:
|
||||
raise CartError(error_messages['ended'])
|
||||
def _delete_expired(self, expired: List[CartPosition]):
|
||||
for cp in expired:
|
||||
if cp.expires <= self.now_dt:
|
||||
cp.delete()
|
||||
|
||||
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]
|
||||
)}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
ItemVariation.objects.filter(item__event=self.event).prefetch_related(
|
||||
'quotas'
|
||||
).select_related('item', 'item__event').filter(
|
||||
id__in=[i for i in variation_ids if i and i not in self._variations_cache]
|
||||
)}
|
||||
)
|
||||
|
||||
def _add_new_items(event: Event, items: List[dict],
|
||||
cart_id: str, expiry: datetime, now_dt: datetime) -> Optional[str]:
|
||||
err = None
|
||||
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)])
|
||||
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,))
|
||||
|
||||
# Fetch items from the database
|
||||
items_query = Item.objects.filter(event=event, id__in=[i['item'] for i in items]).prefetch_related(
|
||||
"quotas")
|
||||
items_cache = {i.id: i for i in items_query}
|
||||
variations_query = ItemVariation.objects.filter(
|
||||
item__event=event,
|
||||
id__in=[i['variation'] for i in items if i['variation'] is not None]
|
||||
).select_related("item", "item__event").prefetch_related("quotas")
|
||||
variations_cache = {v.id: v for v in variations_query}
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
if op.item.require_voucher and op.voucher is None:
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
for i in items:
|
||||
# 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 i['item'] not in items_cache or (i['variation'] is not None and i['variation'] not in variations_cache):
|
||||
err = err or error_messages['not_for_sale']
|
||||
continue
|
||||
if op.item.hide_without_voucher and (op.voucher is None or op.voucher.item is None or op.voucher.item.pk != op.item.pk):
|
||||
raise CartError(error_messages['voucher_required'])
|
||||
|
||||
item = items_cache[i['item']]
|
||||
variation = variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
if not op.item.is_available() or (op.variation and not op.variation.active):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
# Check whether a voucher has been provided
|
||||
voucher = None
|
||||
if i.get('voucher'):
|
||||
try:
|
||||
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
|
||||
if voucher.redeemed >= voucher.max_usages:
|
||||
return error_messages['voucher_redeemed']
|
||||
if voucher.valid_until is not None and voucher.valid_until < now_dt:
|
||||
return error_messages['voucher_expired']
|
||||
if not voucher.applies_to(item, variation):
|
||||
return error_messages['voucher_invalid_item']
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=event) &
|
||||
(Q(expires__gte=now_dt) | Q(cart_id=cart_id))
|
||||
)
|
||||
if 'cp' in i:
|
||||
redeemed_in_carts = redeemed_in_carts.exclude(pk=i['cp'].pk)
|
||||
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
|
||||
|
||||
if v_avail < 1:
|
||||
return error_messages['voucher_redeemed']
|
||||
if i['count'] > v_avail:
|
||||
return error_messages['voucher_redeemed_partial'] % v_avail
|
||||
|
||||
except Voucher.DoesNotExist:
|
||||
return error_messages['voucher_invalid']
|
||||
|
||||
# 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 voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]:
|
||||
return error_messages['voucher_invalid_item']
|
||||
|
||||
if item.require_voucher and voucher is None:
|
||||
return error_messages['voucher_required']
|
||||
|
||||
if item.hide_without_voucher and (voucher is None or voucher.item is None or voucher.item.pk != item.pk):
|
||||
return error_messages['voucher_required']
|
||||
|
||||
if len(quotas) == 0 or not item.is_available() or (variation and not variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
continue
|
||||
|
||||
# Check that all quotas allow us to buy i['count'] instances of the object
|
||||
quota_ok = i['count']
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
for quota in quotas:
|
||||
avail = quota.availability()
|
||||
if avail[1] is not None and avail[1] < i['count']:
|
||||
# This quota is not available or less than i['count'] items are left, so we have to
|
||||
# reduce the number of bought items
|
||||
if avail[0] != Quota.AVAILABILITY_OK:
|
||||
err = err or error_messages['unavailable']
|
||||
else:
|
||||
err = err or error_messages['in_part']
|
||||
quota_ok = min(quota_ok, avail[1])
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
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 (
|
||||
variation.default_price if variation.default_price is not None else item.default_price)
|
||||
variation.default_price if variation.default_price is not None else item.default_price
|
||||
)
|
||||
if voucher:
|
||||
price = voucher.calculate_price(price)
|
||||
|
||||
if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "":
|
||||
custom_price = i['price']
|
||||
if item.free_price and custom_price is not None and custom_price != "":
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
return 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)
|
||||
|
||||
# Create a CartPosition for as much items as we can
|
||||
for k in range(quota_ok):
|
||||
if 'cp' in i and i['count'] == 1:
|
||||
# Recreating
|
||||
cp = i['cp']
|
||||
cp.expires = expiry
|
||||
cp.price = price
|
||||
cp.save()
|
||||
return price
|
||||
|
||||
def extend_expired_positions(self):
|
||||
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
|
||||
'item', 'variation', 'voucher'
|
||||
).prefetch_related('item__quotas', 'variation__quotas')
|
||||
for cp in expired:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price)
|
||||
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
|
||||
for quota in quotas:
|
||||
self._quota_diff[quota] += 1
|
||||
else:
|
||||
CartPosition.objects.create(
|
||||
event=event, item=item, variation=variation,
|
||||
price=price,
|
||||
expires=expiry,
|
||||
cart_id=cart_id, voucher=voucher
|
||||
)
|
||||
return err
|
||||
quotas = []
|
||||
|
||||
op = self.ExtendOperation(
|
||||
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
|
||||
price=price, quotas=quotas
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
|
||||
def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
|
||||
with event.lock() as now_dt:
|
||||
_check_date(event, now_dt)
|
||||
existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count()
|
||||
if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,))
|
||||
if cp.voucher:
|
||||
self._voucher_use_diff[cp.voucher] += 1
|
||||
|
||||
expiry = now_dt + timedelta(minutes=event.settings.get('reservation_time', as_type=int))
|
||||
_extend_existing(event, cart_id, expiry, now_dt)
|
||||
self._operations.append(op)
|
||||
|
||||
expired = _re_add_expired_positions(items, event, cart_id, now_dt)
|
||||
if items:
|
||||
err = _add_new_items(event, items, cart_id, expiry, now_dt)
|
||||
_delete_expired(expired, now_dt)
|
||||
def add_new_items(self, items: List[dict]):
|
||||
# Fetch items from the database
|
||||
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
|
||||
quota_diff = Counter()
|
||||
voucher_use_diff = Counter()
|
||||
operations = []
|
||||
|
||||
for i in items:
|
||||
# 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 i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
item = self._items_cache[i['item']]
|
||||
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
|
||||
voucher = None
|
||||
|
||||
if i.get('voucher'):
|
||||
try:
|
||||
voucher = self.event.vouchers.get(code=i.get('voucher').strip())
|
||||
except Voucher.DoesNotExist:
|
||||
raise CartError(error_messages['voucher_invalid'])
|
||||
else:
|
||||
voucher_use_diff[voucher] += i['count']
|
||||
|
||||
# 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'])
|
||||
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += i['count']
|
||||
else:
|
||||
quotas = []
|
||||
|
||||
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
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff += quota_diff
|
||||
self._voucher_use_diff += voucher_use_diff
|
||||
self._operations += operations
|
||||
|
||||
def remove_items(self, items: List[dict]):
|
||||
# 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))
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = {}
|
||||
for quota, count in self._quota_diff.items():
|
||||
avail = quota.availability(self.now_dt)
|
||||
if avail[1] is not None and avail[1] < count:
|
||||
quotas_ok[quota] = min(count, avail[1])
|
||||
else:
|
||||
quotas_ok[quota] = count
|
||||
return quotas_ok
|
||||
|
||||
def _get_voucher_availability(self):
|
||||
vouchers_ok = {}
|
||||
for voucher, count in self._voucher_use_diff.items():
|
||||
voucher.refresh_from_db()
|
||||
|
||||
if voucher.valid_until is not None and voucher.valid_until < self.now_dt:
|
||||
raise CartError(error_messages['voucher_expired'])
|
||||
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=voucher) & Q(event=self.event) &
|
||||
Q(expires__gte=self.now_dt)
|
||||
).exclude(pk__in=[
|
||||
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
|
||||
])
|
||||
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
|
||||
vouchers_ok[voucher] = v_avail
|
||||
|
||||
return vouchers_ok
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.RemoveOperation):
|
||||
op.position.delete()
|
||||
|
||||
elif isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
# Create a CartPosition for as much items as we can
|
||||
requested_count = quota_available_count = voucher_available_count = op.count
|
||||
|
||||
if op.quotas:
|
||||
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
|
||||
|
||||
if op.voucher:
|
||||
voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher])
|
||||
|
||||
if quota_available_count < 1:
|
||||
err = err or error_messages['unavailable']
|
||||
elif quota_available_count < requested_count:
|
||||
err = err or error_messages['in_part']
|
||||
|
||||
if voucher_available_count < 1:
|
||||
err = err or error_messages['voucher_redeemed']
|
||||
elif voucher_available_count < requested_count:
|
||||
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count
|
||||
|
||||
available_count = min(quota_available_count, voucher_available_count)
|
||||
|
||||
for q in op.quotas:
|
||||
quotas_ok[q] -= available_count
|
||||
if op.voucher:
|
||||
vouchers_ok[op.voucher] -= available_count
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
for k in range(available_count):
|
||||
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
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
op.position.expires = self._expiry
|
||||
op.position.price = op.price
|
||||
op.position.save()
|
||||
elif available_count == 0:
|
||||
op.position.delete()
|
||||
else:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
|
||||
CartPosition.objects.bulk_create(new_cart_positions)
|
||||
return err
|
||||
|
||||
def commit(self):
|
||||
self._check_presale_dates()
|
||||
self._check_max_cart_size()
|
||||
self._calculate_expiry()
|
||||
|
||||
with self.event.lock() as now_dt:
|
||||
with transaction.atomic():
|
||||
self.now_dt = now_dt
|
||||
self._extend_expiry_of_valid_existing_positions()
|
||||
self.extend_expired_positions()
|
||||
err = self._perform_operations()
|
||||
if err:
|
||||
raise CartError(err)
|
||||
|
||||
@@ -231,34 +365,15 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) ->
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
_add_items_to_cart(event, items, cart_id)
|
||||
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'])
|
||||
|
||||
|
||||
def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> None:
|
||||
with event.lock():
|
||||
for i in items:
|
||||
cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=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:
|
||||
cp.delete()
|
||||
cnt -= len(correctprice)
|
||||
if cnt > 0:
|
||||
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
|
||||
cp.delete()
|
||||
|
||||
|
||||
@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:
|
||||
"""
|
||||
@@ -270,7 +385,9 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
_remove_items_from_cart(event, items, cart_id)
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -3,17 +3,18 @@ import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from locale import format as lformat
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import (
|
||||
@@ -21,7 +22,7 @@ from reportlab.platypus import (
|
||||
Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
||||
from pretix.base.services.async import TransactionAwareTask
|
||||
from pretix.base.signals import register_payment_providers
|
||||
@@ -182,19 +183,23 @@ def _invoice_generate_german(invoice, f):
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_from.strip())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_from.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_to.strip())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
p = Paragraph(invoice.invoice_to.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 85 * mm, 50 * mm)
|
||||
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 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if invoice.is_cancellation:
|
||||
@@ -252,18 +257,33 @@ def _invoice_generate_german(invoice, f):
|
||||
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),
|
||||
95 * mm, (297 - 38) * mm,
|
||||
width=25 * mm, height=25 * mm,
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
mask='auto')
|
||||
|
||||
if invoice.event.settings.show_date_to:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + _('{from_date}\nuntil {to_date}').format(
|
||||
from_date=invoice.event.get_date_from_display(),
|
||||
to_date=invoice.event.get_date_to_display())
|
||||
)
|
||||
else:
|
||||
p_str = (
|
||||
str(invoice.event.name) + '\n' + invoice.event.get_date_from_display()
|
||||
)
|
||||
|
||||
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=styles['Normal'])
|
||||
p.wrapOn(canvas, 65 * mm, 50 * mm)
|
||||
p_size = p.wrap(65 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Event').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(str(invoice.event.name))
|
||||
if invoice.event.settings.show_date_to:
|
||||
textobject.textLines(
|
||||
_('{from_date}\nuntil {to_date}').format(from_date=invoice.event.get_date_from_display(),
|
||||
to_date=invoice.event.get_date_to_display()))
|
||||
else:
|
||||
textobject.textLine(invoice.event.get_date_from_display())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
canvas.restoreState()
|
||||
@@ -306,6 +326,7 @@ def _invoice_generate_german(invoice, f):
|
||||
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
@@ -320,16 +341,16 @@ def _invoice_generate_german(invoice, f):
|
||||
total = Decimal('0.00')
|
||||
for line in invoice.lines.all():
|
||||
tdata.append((
|
||||
line.description,
|
||||
lformat("%.2f", line.tax_rate) + " %",
|
||||
lformat("%.2f", line.net_value) + " " + invoice.event.currency,
|
||||
lformat("%.2f", line.gross_value) + " " + invoice.event.currency,
|
||||
Paragraph(line.description, styles['Normal']),
|
||||
localize(line.tax_rate) + " %",
|
||||
localize(line.net_value) + " " + invoice.event.currency,
|
||||
localize(line.gross_value) + " " + invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate] += line.tax_value
|
||||
grossvalue_map[line.tax_rate] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', '', lformat("%.2f", total) + " " + invoice.event.currency])
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', '', localize(total) + " " + invoice.event.currency])
|
||||
colwidths = [a * doc.width for a in (.55, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
@@ -361,10 +382,10 @@ def _invoice_generate_german(invoice, f):
|
||||
tax = taxvalue_map[rate]
|
||||
tdata.append((
|
||||
'',
|
||||
lformat("%.2f", rate) + " %",
|
||||
lformat("%.2f", (gross - tax)) + " " + invoice.event.currency,
|
||||
lformat("%.2f", gross) + " " + invoice.event.currency,
|
||||
lformat("%.2f", tax) + " " + invoice.event.currency,
|
||||
localize(rate) + " %",
|
||||
localize((gross - tax)) + " " + invoice.event.currency,
|
||||
localize(gross) + " " + invoice.event.currency,
|
||||
localize(tax) + " " + invoice.event.currency,
|
||||
))
|
||||
|
||||
if len(tdata) > 2:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
import bleach
|
||||
import cssutils
|
||||
@@ -8,10 +8,11 @@ from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from inlinestyler.utils import inline_css
|
||||
|
||||
from pretix.base.i18n import LazyI18nString, language
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, InvoiceAddress, Order
|
||||
from pretix.celery_app import app
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -30,7 +31,7 @@ class SendMailException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def mail(email: str, subject: str, template: str,
|
||||
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
|
||||
order: Order=None, headers: dict=None):
|
||||
"""
|
||||
@@ -43,7 +44,7 @@ def mail(email: str, subject: str, template: str,
|
||||
|
||||
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale
|
||||
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
|
||||
``context`` will be used as the argument to a Python ``.format()`` call on the template.
|
||||
``context`` will be used as the argument to a Python ``.format_map()`` call on the template.
|
||||
|
||||
:param context: The context for rendering the template (see ``template`` parameter)
|
||||
|
||||
@@ -63,7 +64,20 @@ def mail(email: str, subject: str, template: str,
|
||||
if email == INVALID_ADDRESS:
|
||||
return
|
||||
|
||||
headers = headers or {}
|
||||
|
||||
with language(locale):
|
||||
if isinstance(context, dict) and order:
|
||||
try:
|
||||
context.update({
|
||||
'invoice_name': order.invoice_address.name,
|
||||
'invoice_company': order.invoice_address.company
|
||||
})
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
context.update({
|
||||
'invoice_name': '',
|
||||
'invoice_company': ''
|
||||
})
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
if context:
|
||||
@@ -92,6 +106,9 @@ def mail(email: str, subject: str, template: str,
|
||||
htmlctx['event'] = event
|
||||
htmlctx['color'] = event.settings.primary_color
|
||||
|
||||
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail:
|
||||
headers['Reply-To'] = event.settings.contact_mail
|
||||
|
||||
prefix = event.settings.get('mail_prefix')
|
||||
if prefix:
|
||||
subject = "[%s] %s" % (prefix, subject)
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import List, Optional
|
||||
|
||||
import pytz
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.dispatch import receiver
|
||||
@@ -63,7 +64,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
|
||||
force: bool=False, send_mail: bool=True, user: User=None) -> Order:
|
||||
force: bool=False, send_mail: bool=True, user: User=None, mail_text='') -> Order:
|
||||
"""
|
||||
Marks an order as paid. This sets the payment provider, info and date and returns
|
||||
the order object.
|
||||
@@ -81,8 +82,13 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
|
||||
:type send_mail: boolean
|
||||
:param user: The user that performed the change
|
||||
:param mail_text: Additional text to be included in the email
|
||||
:type mail_text: str
|
||||
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
|
||||
"""
|
||||
if order.status == Order.STATUS_PAID:
|
||||
return order
|
||||
|
||||
with order.event.lock() as now_dt:
|
||||
can_be_paid = order._can_be_paid()
|
||||
if not force and can_be_paid is not True:
|
||||
@@ -98,14 +104,24 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
order.log_action('pretix.event.order.paid', {
|
||||
'provider': provider,
|
||||
'info': info,
|
||||
'date': date,
|
||||
'date': date or now_dt,
|
||||
'manual': manual,
|
||||
'force': force
|
||||
}, user=user)
|
||||
order_paid.send(order.event, order=order)
|
||||
|
||||
if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
|
||||
if not order.invoices.exists():
|
||||
generate_invoice(order)
|
||||
|
||||
if send_mail:
|
||||
with language(order.locale):
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
order.email, _('Payment received for your order: %(code)s') % {'code': order.code},
|
||||
order.event.settings.mail_text_order_paid,
|
||||
@@ -115,7 +131,10 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool)
|
||||
'downloads': order.event.settings.get('ticket_download', as_type=bool),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
'payment_info': mail_text
|
||||
},
|
||||
order.event, locale=order.locale
|
||||
)
|
||||
@@ -171,7 +190,7 @@ def _cancel_order(order, user=None):
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
|
||||
return order
|
||||
return order.pk
|
||||
|
||||
|
||||
class OrderError(LazyLocaleException):
|
||||
@@ -270,7 +289,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
raise OrderError(err)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
payment_provider: BasePaymentProvider, locale: str=None, address: int=None,
|
||||
meta_info: dict=None):
|
||||
@@ -299,33 +317,35 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
if last_date < expires:
|
||||
expires = last_date
|
||||
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
email=email,
|
||||
datetime=now_dt,
|
||||
expires=expires,
|
||||
locale=locale,
|
||||
total=total,
|
||||
payment_fee=payment_fee,
|
||||
payment_provider=payment_provider.identifier,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
)
|
||||
OrderPosition.transform_cart_positions(positions, order)
|
||||
with transaction.atomic():
|
||||
order = Order.objects.create(
|
||||
status=Order.STATUS_PENDING,
|
||||
event=event,
|
||||
email=email,
|
||||
datetime=now_dt,
|
||||
expires=expires,
|
||||
locale=locale,
|
||||
total=total,
|
||||
payment_fee=payment_fee,
|
||||
payment_provider=payment_provider.identifier,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
)
|
||||
OrderPosition.transform_cart_positions(positions, order)
|
||||
|
||||
if address is not None:
|
||||
try:
|
||||
addr = InvoiceAddress.objects.get(
|
||||
pk=address
|
||||
)
|
||||
if addr.order is not None:
|
||||
addr.pk = None
|
||||
addr.order = order
|
||||
addr.save()
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
if address is not None:
|
||||
try:
|
||||
addr = InvoiceAddress.objects.get(
|
||||
pk=address
|
||||
)
|
||||
if addr.order is not None:
|
||||
addr.pk = None
|
||||
addr.order = order
|
||||
addr.save()
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
order.log_action('pretix.event.order.placed')
|
||||
|
||||
order.log_action('pretix.event.order.placed')
|
||||
order_placed.send(event, order=order)
|
||||
return order
|
||||
|
||||
@@ -363,6 +383,14 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
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 = ""
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
@@ -375,7 +403,9 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order))
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
@@ -404,29 +434,39 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
today = now().replace(hour=0, minute=0, second=0)
|
||||
|
||||
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'):
|
||||
settings = eventcache.get(o.event.pk, None)
|
||||
if settings is None:
|
||||
settings = o.event.settings
|
||||
eventcache[o.event.pk] = settings
|
||||
eventsettings = eventcache.get(o.event.pk, None)
|
||||
if eventsettings is None:
|
||||
eventsettings = o.event.settings
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
|
||||
days = settings.get('mail_days_order_expire_warning', as_type=int)
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
|
||||
if days and (o.expires - today).days <= days:
|
||||
o.expiry_reminder_sent = True
|
||||
o.save()
|
||||
try:
|
||||
mail(
|
||||
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
|
||||
settings.mail_text_order_expire_warning,
|
||||
{
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires, 'SHORT_DATE_FORMAT')
|
||||
},
|
||||
o.event, locale=o.locale
|
||||
)
|
||||
invoice_name = o.invoice_address.name
|
||||
invoice_company = o.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
try:
|
||||
with language(o.locale):
|
||||
mail(
|
||||
o.email, _('Your order is about to expire: %(code)s') % {'code': o.code},
|
||||
eventsettings.mail_text_order_expire_warning,
|
||||
{
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
o.event, locale=o.locale
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
else:
|
||||
@@ -440,9 +480,11 @@ class OrderChangeManager:
|
||||
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
|
||||
'product_invalid': _('The selected product is not active or has no price set.'),
|
||||
'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'),
|
||||
'not_pending': _('Only pending orders can be changed.'),
|
||||
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
|
||||
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
|
||||
'no quota is available.'),
|
||||
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
|
||||
'price of the order as partial payments or refunds are not yet supported.')
|
||||
}
|
||||
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
|
||||
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
|
||||
@@ -458,8 +500,7 @@ class OrderChangeManager:
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
price = item.default_price if variation is None else (
|
||||
variation.default_price if variation.default_price is not None else item.default_price)
|
||||
price = item.default_price if variation is None else variation.price
|
||||
if not price:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
self._totaldiff = price - position.price
|
||||
@@ -488,6 +529,10 @@ class OrderChangeManager:
|
||||
if self.order.total == Decimal('0.00') and self._totaldiff > 0:
|
||||
raise OrderError(self.error_messages['free_to_paid'])
|
||||
|
||||
def _check_paid_price_change(self):
|
||||
if self.order.status == Order.STATUS_PAID and self._totaldiff != 0:
|
||||
raise OrderError(self.error_messages['paid_price_change'])
|
||||
|
||||
def _check_paid_to_free(self):
|
||||
if self.order.total == 0:
|
||||
try:
|
||||
@@ -557,6 +602,12 @@ class OrderChangeManager:
|
||||
|
||||
def _notify_user(self):
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
self.order.email, _('Your order has been changed: %(code)s') % {'code': self.order.code},
|
||||
self.order.event.settings.mail_text_order_changed,
|
||||
@@ -566,6 +617,8 @@ class OrderChangeManager:
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
self.order.event, locale=self.order.locale
|
||||
)
|
||||
@@ -576,9 +629,10 @@ class OrderChangeManager:
|
||||
return
|
||||
with transaction.atomic():
|
||||
with self.order.event.lock():
|
||||
if self.order.status != Order.STATUS_PENDING:
|
||||
raise OrderError(self.error_messages['not_pending'])
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||
self._check_free_to_paid()
|
||||
self._check_paid_price_change()
|
||||
self._check_quotas()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
|
||||
@@ -5,7 +5,9 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import CachedTicket, Event, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
@@ -37,6 +39,31 @@ def generate(order_position: str, provider: str):
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate_order(order: int, provider: str):
|
||||
order = Order.objects.select_related('event').get(id=order)
|
||||
try:
|
||||
ct = CachedCombinedTicket.objects.get(order=order, provider=provider)
|
||||
except CachedCombinedTicket.MultipleObjectsReturned:
|
||||
CachedCombinedTicket.objects.filter(order=order, provider=provider).delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
except CachedCombinedTicket.DoesNotExist:
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension='',
|
||||
type='', file=None)
|
||||
|
||||
with language(order.locale):
|
||||
responses = register_ticket_outputs.send(order.event)
|
||||
for receiver, response in responses:
|
||||
prov = response(order.event)
|
||||
if prov.identifier == provider:
|
||||
filename, ct.type, data = prov.generate_order(order)
|
||||
path, ext = os.path.splitext(filename)
|
||||
ct.extension = ext
|
||||
ct.save()
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
60
src/pretix/base/services/waitinglist.py
Normal file
60
src/pretix/base/services/waitinglist.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.dispatch import receiver
|
||||
|
||||
from pretix.base.models import Event, User, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.async import ProfiledTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def assign_automatically(event_id: int, user_id: int=None):
|
||||
event = Event.objects.get(id=event_id)
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id)
|
||||
else:
|
||||
user = None
|
||||
|
||||
quota_cache = {}
|
||||
gone = set()
|
||||
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=event, voucher__isnull=True
|
||||
).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created')
|
||||
sent = 0
|
||||
|
||||
for wle in qs:
|
||||
if (wle.item, wle.variation) in gone:
|
||||
continue
|
||||
|
||||
quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all()
|
||||
availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
if availability[1] > 0:
|
||||
try:
|
||||
wle.send_voucher(quota_cache, user=user)
|
||||
sent += 1
|
||||
except WaitingListException: # noqa
|
||||
continue
|
||||
|
||||
# Reduce affected quotas in cache
|
||||
for q in quotas:
|
||||
quota_cache[q.pk] = (
|
||||
quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0,
|
||||
quota_cache[q.pk][1] - 1
|
||||
)
|
||||
else:
|
||||
gone.add((wle.item, wle.variation))
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def process_waitinglist(sender, **kwargs):
|
||||
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_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,))
|
||||
@@ -12,7 +12,7 @@ from django.core.files.storage import default_storage
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import ugettext_noop
|
||||
|
||||
from pretix.base.i18n import LazyI18nString
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pretix.base.models.settings import GlobalSetting
|
||||
|
||||
DEFAULTS = {
|
||||
@@ -20,6 +20,10 @@ DEFAULTS = {
|
||||
'default': '10',
|
||||
'type': int
|
||||
},
|
||||
'display_net_prices': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'attendee_names_asked': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
@@ -128,6 +132,22 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'show_variations_expanded': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_enabled': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_auto': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'waiting_list_hours': {
|
||||
'default': '48',
|
||||
'type': int
|
||||
},
|
||||
'ticket_download': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
@@ -231,6 +251,8 @@ Your {event} team"""))
|
||||
|
||||
we successfully received your payment for {event}. Thank you!
|
||||
|
||||
{payment_info}
|
||||
|
||||
You can change your order details and view the status of your order at
|
||||
{url}
|
||||
|
||||
@@ -252,6 +274,29 @@ your payment before {expire_date}.
|
||||
You can view the payment information and the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_waiting_list': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
|
||||
|
||||
you submitted yourself to the waiting list for {event},
|
||||
for the product {product}.
|
||||
|
||||
We now have a ticket ready for you! You can redeem it in our ticket shop
|
||||
within the next {hours} hours by entering the following voucher code:
|
||||
|
||||
{code}
|
||||
|
||||
Alternatively, you can just click on the following link:
|
||||
|
||||
{url}
|
||||
|
||||
Please note that this link is only valid within the next {hours} hours!
|
||||
We will reassign the ticket to the next person on the list if you do not
|
||||
redeem the voucher within that timeframe.
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -299,6 +344,10 @@ Your {event} team"""))
|
||||
'default': None,
|
||||
'type': File
|
||||
},
|
||||
'invoice_logo_image': {
|
||||
'default': None,
|
||||
'type': File
|
||||
},
|
||||
'frontpage_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
@@ -359,7 +408,7 @@ class SettingsProxy:
|
||||
settings[key] = self.get(key)
|
||||
return settings
|
||||
|
||||
def _unserialize(self, value: str, as_type: type) -> Any:
|
||||
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
|
||||
|
||||
@@ -375,7 +424,7 @@ class SettingsProxy:
|
||||
return value == 'True'
|
||||
elif as_type == File:
|
||||
try:
|
||||
fi = default_storage.open(value[7:], 'r')
|
||||
fi = default_storage.open(value[7:], 'rb' if binary_file else 'r')
|
||||
fi.url = default_storage.url(value[7:])
|
||||
return fi
|
||||
except OSError:
|
||||
@@ -414,7 +463,7 @@ class SettingsProxy:
|
||||
|
||||
raise TypeError('Unable to serialize %s into a setting.' % str(type(value)))
|
||||
|
||||
def get(self, key: str, default=None, as_type: type=None):
|
||||
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
|
||||
@@ -440,7 +489,7 @@ class SettingsProxy:
|
||||
if value is None and default is not None:
|
||||
value = default
|
||||
|
||||
return self._unserialize(value, as_type)
|
||||
return self._unserialize(value, as_type, binary_file=binary_file)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.get(key)
|
||||
|
||||
@@ -94,6 +94,18 @@ subclass of pretix.base.exporter.BaseExporter
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
validate_cart = EventPluginSignal(
|
||||
providing_args=["positions"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out before the user starts checkout. It includes an iterable
|
||||
with the current CartPosition objects.
|
||||
The response of receivers will be ignored, but you can raise a CartError with an
|
||||
appropriate exception message.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
order_placed = EventPluginSignal(
|
||||
providing_args=["order"]
|
||||
)
|
||||
|
||||
45
src/pretix/base/templatetags/rich_text.py
Normal file
45
src/pretix/base/templatetags/rich_text.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import bleach
|
||||
import markdown
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
'a',
|
||||
'abbr',
|
||||
'acronym',
|
||||
'b',
|
||||
'blockquote',
|
||||
'code',
|
||||
'em',
|
||||
'i',
|
||||
'li',
|
||||
'ol',
|
||||
'strong',
|
||||
'ul',
|
||||
'p',
|
||||
'table',
|
||||
'tbody',
|
||||
'thead',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
'a': ['href', 'title'],
|
||||
'abbr': ['title'],
|
||||
'acronym': ['title'],
|
||||
'table': ['width'],
|
||||
'td': ['width', 'align'],
|
||||
}
|
||||
|
||||
|
||||
@register.filter
|
||||
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))
|
||||
return mark_safe(body_md)
|
||||
@@ -1,11 +1,14 @@
|
||||
import os
|
||||
import tempfile
|
||||
from collections import OrderedDict
|
||||
from typing import Tuple
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django import forms
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
from pretix.base.models import Event, Order, OrderPosition
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
|
||||
|
||||
@@ -29,7 +32,7 @@ class BaseTicketOutput:
|
||||
"""
|
||||
return self.settings.get('_enabled', as_type=bool)
|
||||
|
||||
def generate(self, order: OrderPosition) -> Tuple[str, str, str]:
|
||||
def generate(self, position: OrderPosition) -> Tuple[str, str, str]:
|
||||
"""
|
||||
This method should generate the download file and return a tuple consisting of a
|
||||
filename, a file type and file content. The extension will be taken from the filename
|
||||
@@ -37,6 +40,29 @@ class BaseTicketOutput:
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_order(self, order: Order) -> Tuple[str, str, str]:
|
||||
"""
|
||||
This method is the same as order() but should not generate one file per order position
|
||||
but instead one file for the full order.
|
||||
|
||||
This method is optional to implement. If you don't implement it, the default
|
||||
implementation will offer a zip file of the generate() results for the order positions.
|
||||
|
||||
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.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for pos in order.positions.all():
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
), content)
|
||||
|
||||
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
|
||||
return '{}-{}.zip'.format(order.code, self.identifier), 'application/zip', zipf.read()
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,7 @@ 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')
|
||||
|
||||
@@ -31,10 +32,11 @@ class AsyncAction:
|
||||
return JsonResponse(data)
|
||||
else:
|
||||
if res.ready():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
with casual_reads():
|
||||
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):
|
||||
@@ -64,24 +66,25 @@ class AsyncAction:
|
||||
'ready': ready
|
||||
}
|
||||
if ready:
|
||||
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))
|
||||
})
|
||||
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))
|
||||
})
|
||||
return data
|
||||
|
||||
def get_result(self, request):
|
||||
@@ -90,10 +93,11 @@ class AsyncAction:
|
||||
return JsonResponse(self._return_ajax_result(res, timeout=0.25))
|
||||
else:
|
||||
if res.ready():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
with casual_reads():
|
||||
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):
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.http import etag
|
||||
from django.views.i18n import (
|
||||
get_javascript_catalog, render_javascript_catalog, to_locale,
|
||||
)
|
||||
from django.views.i18n import get_javascript_catalog, render_javascript_catalog
|
||||
|
||||
# Yes, we want to regenerate this every time the module has been imported to
|
||||
# refresh the cache at least at every code deployment
|
||||
@@ -21,5 +19,5 @@ js_info_dict = {
|
||||
@cache_page(3600, key_prefix='js18n-%s' % import_date)
|
||||
def js_catalog(request, lang):
|
||||
packages = ['pretix']
|
||||
catalog, plural = get_javascript_catalog(to_locale(lang), 'djangojs', packages)
|
||||
catalog, plural = get_javascript_catalog(lang, 'djangojs', packages)
|
||||
return render_javascript_catalog(catalog, plural)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
|
||||
from .signals import html_head, nav_event
|
||||
from .signals import html_head, nav_event, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ def contextprocessor(request):
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
ctx['nav_event'] = _nav_event
|
||||
|
||||
_nav_topbar = []
|
||||
for receiver, response in nav_topbar.send(request, request=request):
|
||||
_nav_topbar += response
|
||||
ctx['nav_topbar'] = _nav_topbar
|
||||
|
||||
ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS')
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
|
||||
@@ -4,10 +4,10 @@ from django.core.exceptions import ValidationError
|
||||
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 pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.control.forms import ExtFileField
|
||||
|
||||
@@ -56,7 +56,8 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
'date_from',
|
||||
'date_to',
|
||||
'presale_start',
|
||||
'presale_end'
|
||||
'presale_end',
|
||||
'location',
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -107,7 +108,7 @@ class EventWizardCopyForm(forms.Form):
|
||||
),
|
||||
widget=forms.RadioSelect,
|
||||
empty_label=_('Do not copy'),
|
||||
required=True
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
@@ -131,6 +132,7 @@ class EventUpdateForm(I18nModelForm):
|
||||
'is_public',
|
||||
'presale_start',
|
||||
'presale_end',
|
||||
'location',
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -156,6 +158,12 @@ class EventSettingsForm(SettingsForm):
|
||||
help_text=_("Show item details before presale has started and after presale has ended"),
|
||||
required=False
|
||||
)
|
||||
display_net_prices = forms.BooleanField(
|
||||
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
|
||||
help_text=_("Independent of your choice, the cart will show gross prices as this the price that needs to be "
|
||||
"paid"),
|
||||
required=False
|
||||
)
|
||||
presale_start_show_date = forms.BooleanField(
|
||||
label=_("Show start date"),
|
||||
help_text=_("Show the presale start date before presale has started."),
|
||||
@@ -185,6 +193,27 @@ class EventSettingsForm(SettingsForm):
|
||||
help_text=_("Publicly show how many tickets of a certain type are still available."),
|
||||
required=False
|
||||
)
|
||||
waiting_list_enabled = forms.BooleanField(
|
||||
label=_("Enable waiting list"),
|
||||
help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket "
|
||||
"becomes available again, it will be reserved for the first person on the waiting list and this "
|
||||
"person will receive an email notification with a voucher that can be used to buy a ticket."),
|
||||
required=False
|
||||
)
|
||||
waiting_list_hours = forms.IntegerField(
|
||||
label=_("Waiting list response time"),
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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."),
|
||||
@@ -271,6 +300,17 @@ class PaymentSettingsForm(SettingsForm):
|
||||
"(in percent)."),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
payment_term_last = cleaned_data.get('payment_term_last')
|
||||
if payment_term_last and self.obj.presale_end:
|
||||
if payment_term_last < self.obj.presale_end.date():
|
||||
self.add_error(
|
||||
'payment_term_last',
|
||||
_('The last payment date cannot be before the end of presale.'),
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
@@ -329,7 +369,8 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
('False', _('No')),
|
||||
('admin', _('Manually in admin panel')),
|
||||
('user', _('Automatically on user request')),
|
||||
('True', _('Automatically for all created orders'))
|
||||
('True', _('Automatically for all created orders')),
|
||||
('paid', _('Automatically on payment')),
|
||||
)
|
||||
)
|
||||
invoice_address_from = forms.CharField(
|
||||
@@ -361,6 +402,12 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
label=_("Invoice language"),
|
||||
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
||||
)
|
||||
invoice_logo_image = ExtFileField(
|
||||
label=_('Logo image'),
|
||||
ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('We will show your logo with a maximal height and width of 2.5 cm.')
|
||||
)
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
@@ -378,31 +425,32 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}")
|
||||
help_text=_("Available placeholders: {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}")
|
||||
help_text=_("Available placeholders: {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}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
)
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}")
|
||||
help_text=_("Available placeholders: {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}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
)
|
||||
mail_text_resend_all_links = I18nFormField(
|
||||
label=_("Text (requested by user)"),
|
||||
@@ -421,7 +469,13 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}")
|
||||
help_text=_("Available placeholders: {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}")
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
@@ -491,6 +545,10 @@ class DisplaySettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=I18nTextarea
|
||||
)
|
||||
show_variations_expanded = forms.BooleanField(
|
||||
label=_("Show variations of a product expanded by default"),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class TicketSettingsForm(SettingsForm):
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextInput
|
||||
|
||||
from pretix.base.forms import SettingsForm
|
||||
from pretix.base.i18n import I18nFormField, I18nTextInput
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import register_global_settings
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError
|
||||
from django.forms import BooleanField, ModelMultipleChoiceField
|
||||
from django.forms.formsets import DELETION_FIELD_NAME
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
@@ -105,12 +105,32 @@ class ItemCreateForm(I18nModelForm):
|
||||
'You can select the variations in the next step.'),
|
||||
required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['copy_from'] = forms.ModelChoiceField(
|
||||
label=_("Copy product information"),
|
||||
queryset=self.event.items.all(),
|
||||
widget=forms.Select,
|
||||
empty_label=_('Do not copy'),
|
||||
required=False
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
if self.cleaned_data.get('has_variations'):
|
||||
ItemVariation.objects.create(
|
||||
item=instance, value=__('Standard')
|
||||
)
|
||||
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
||||
for variation in self.cleaned_data['copy_from'].variations.all():
|
||||
ItemVariation.objects.create(item=instance, value=variation.value, active=variation.active,
|
||||
position=variation.position, default_price=variation.default_price)
|
||||
else:
|
||||
ItemVariation.objects.create(
|
||||
item=instance, value=__('Standard')
|
||||
)
|
||||
|
||||
for question in Question.objects.filter(items=self.cleaned_data.get('copy_from')):
|
||||
question.items.add(instance)
|
||||
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
@@ -58,7 +59,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
price = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
label=_('New price')
|
||||
label=_('New price (gross)')
|
||||
)
|
||||
operation = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -88,15 +89,18 @@ class OrderPositionChangeForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
choices = []
|
||||
for i in instance.order.event.items.prefetch_related('variations').all():
|
||||
pname = i.name
|
||||
pname = str(i.name)
|
||||
if not i.is_available():
|
||||
pname += ' ({})'.format(_('inactive'))
|
||||
variations = list(i.variations.all())
|
||||
if variations:
|
||||
for v in variations:
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (pname, v.value)))
|
||||
choices.append(('%d-%d' % (i.pk, v.pk),
|
||||
'%s – %s (%s %s)' % (pname, v.value, localize(v.price),
|
||||
instance.order.event.currency)))
|
||||
else:
|
||||
choices.append((str(i.pk), pname))
|
||||
choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price),
|
||||
instance.order.event.currency)))
|
||||
self.fields['itemvar'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -4,8 +4,8 @@ from decimal import Decimal
|
||||
from django.dispatch import receiver
|
||||
from django.utils import formats
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import LazyI18nString
|
||||
from pretix.base.models import Event, ItemVariation, LogEntry
|
||||
from pretix.base.signals import logentry_display
|
||||
|
||||
@@ -21,10 +21,10 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
if logentry.action_type == 'pretix.event.order.changed.item':
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(event.itemvariations.get(pk=data['old_variation']))
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation']))
|
||||
new_item = str(event.items.get(pk=data['new_item']))
|
||||
if data['new_variation']:
|
||||
new_item += ' - ' + str(event.itemvariations.get(pk=data['new_variation']))
|
||||
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
|
||||
return text + ' ' + _('Position #{posid}: {old_item} ({old_price} {currency}) changed '
|
||||
'to {new_item} ({new_price} {currency}).').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
@@ -85,6 +85,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
|
||||
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
|
||||
'pretix.voucher.changed': _('The voucher has been modified.'),
|
||||
'pretix.voucher.deleted': _('The voucher has been deleted.'),
|
||||
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
|
||||
@@ -117,6 +118,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
|
||||
'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.')
|
||||
}
|
||||
|
||||
data = json.loads(logentry.data)
|
||||
|
||||
@@ -61,33 +61,53 @@ class PermissionMiddleware(MiddlewareMixin):
|
||||
return redirect_to_login(
|
||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
|
||||
request.user.events_cache = request.user.events.order_by(
|
||||
events = Event.objects.all() if request.user.is_superuser else request.user.events
|
||||
request.user.events_cache = events.order_by(
|
||||
"organizer", "date_from").prefetch_related("organizer")
|
||||
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
||||
try:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
permitted__id__exact=request.user.id,
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer')[0]
|
||||
request.eventperm = EventPermission.objects.get(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
if request.user.is_superuser:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer')[0]
|
||||
request.eventperm = EventPermission(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
else:
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
permitted__id__exact=request.user.id,
|
||||
organizer__slug=url.kwargs['organizer'],
|
||||
).select_related('organizer')[0]
|
||||
request.eventperm = EventPermission.objects.get(
|
||||
event=request.event,
|
||||
user=request.user
|
||||
)
|
||||
request.organizer = request.event.organizer
|
||||
except IndexError:
|
||||
raise Http404(_("The selected event was not found or you "
|
||||
"have no permission to administrate it."))
|
||||
elif 'organizer' in url.kwargs:
|
||||
try:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
permitted__id__exact=request.user.id,
|
||||
)[0]
|
||||
request.orgaperm = OrganizerPermission.objects.get(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
if request.user.is_superuser:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
)[0]
|
||||
request.orgaperm = OrganizerPermission(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
else:
|
||||
request.organizer = Organizer.objects.filter(
|
||||
slug=url.kwargs['organizer'],
|
||||
permitted__id__exact=request.user.id,
|
||||
)[0]
|
||||
request.orgaperm = OrganizerPermission.objects.get(
|
||||
organizer=request.organizer,
|
||||
user=request.user
|
||||
)
|
||||
except IndexError:
|
||||
raise Http404(_("The selected organizer was not found or you "
|
||||
"have no permission to administrate it."))
|
||||
|
||||
@@ -14,6 +14,8 @@ def event_permission_required(permission):
|
||||
if not request.user.is_authenticated: # NOQA
|
||||
# just a double check, should not ever happen
|
||||
raise PermissionDenied()
|
||||
if request.user.is_superuser:
|
||||
return function(request, *args, **kw)
|
||||
try:
|
||||
perm = EventPermission.objects.get(
|
||||
event=request.event,
|
||||
@@ -28,7 +30,7 @@ def event_permission_required(permission):
|
||||
allowed = getattr(perm, permission)
|
||||
except AttributeError:
|
||||
pass
|
||||
if allowed:
|
||||
if allowed or request.user.is_superuser:
|
||||
return function(request, *args, **kw)
|
||||
raise PermissionDenied(_('You do not have permission to view this content.'))
|
||||
return wrapper
|
||||
@@ -58,6 +60,8 @@ def organizer_permission_required(permission):
|
||||
if not request.user.is_authenticated: # NOQA
|
||||
# just a double check, should not ever happen
|
||||
raise PermissionDenied()
|
||||
if request.user.is_superuser:
|
||||
return function(request, *args, **kw)
|
||||
try:
|
||||
perm = OrganizerPermission.objects.get(
|
||||
organizer=request.organizer,
|
||||
|
||||
@@ -28,7 +28,7 @@ nav_event = EventPluginSignal(
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the admin panel
|
||||
navigation. You will get the request as a keyword argument ``return``.
|
||||
navigation. 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
|
||||
@@ -42,6 +42,24 @@ in pretix.
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
nav_topbar = Signal(
|
||||
providing_args=["request"]
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the top navigation bar.
|
||||
You will get the request as a keyword argument ``return``.
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
event_dashboard_widgets = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -104,3 +122,15 @@ 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(
|
||||
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).
|
||||
"""
|
||||
|
||||
@@ -32,13 +32,15 @@
|
||||
<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 "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{{ html_head|safe }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
</head>
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-dateformat="{{ js_date_format }}" data-datetimelocale="{{ js_locale }}" data-payment-weekdays-disabled="{{ js_payment_weekdays_disabled }}">
|
||||
<div id="#wrapper">
|
||||
<div id="wrapper">
|
||||
<nav class="navbar navbar-inverse navbar-static-top" role="navigation">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle"
|
||||
@@ -74,6 +76,35 @@
|
||||
</li>
|
||||
</ul>
|
||||
<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 %}
|
||||
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<span class="fa fa-{{ nav.icon }}"></span>
|
||||
<span class="visible-xs-inline">{{ nav.label }}</span>
|
||||
{% else %}
|
||||
{{ nav.label }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if nav.children %}
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
{% for item in nav.children %}
|
||||
<li>
|
||||
<a href="{{ item.url }}"
|
||||
{% if item.active %}class="active"{% endif %}>
|
||||
{% if item.icon %}
|
||||
<span class="fa fa-{{ item.icon }}"></span>
|
||||
{% endif %}
|
||||
{{ item.label|safe }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
<li>
|
||||
<a href="{% url 'control:user.settings' %}">
|
||||
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
|
||||
|
||||
@@ -84,6 +84,12 @@
|
||||
{% trans "Export" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'control:event.orders.waitinglist' organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
{% if url_name == "event.orders.waitinglist" %}class="active"{% endif %}>
|
||||
{% trans "Waiting list" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% bootstrap_field form.primary_color layout="horizontal" %}
|
||||
{% bootstrap_field form.logo_image layout="horizontal" %}
|
||||
{% bootstrap_field form.frontpage_text layout="horizontal" %}
|
||||
{% bootstrap_field form.show_variations_expanded layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{{ request.event.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<form action="{% eventurl request.event "presale:event.auth" %}" method="post" target="_blank">
|
||||
@@ -51,10 +52,14 @@
|
||||
<div class="row dashboard">
|
||||
{% for w in widgets %}
|
||||
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
|
||||
{% if w.url %}
|
||||
{% if w.url %}{# backwards compatibility #}
|
||||
<a href="{{ w.url }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
</a>
|
||||
{% elif w.link %}
|
||||
<a href="{{ w.link }}" class="widget">
|
||||
{{ w.content|safe }}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="widget">
|
||||
{{ w.content|safe }}
|
||||
@@ -81,6 +86,11 @@
|
||||
<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." %}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
{% bootstrap_field form.invoice_introductory_text layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_additional_text layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_footer_text layout="horizontal" %}
|
||||
{% bootstrap_field form.invoice_logo_image layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue }}</li>
|
||||
<li>{{ issue|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "pretixcontrol/items/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Event logs" %}{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Event logs" %}</h1>
|
||||
@@ -32,6 +33,11 @@
|
||||
<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." %}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -99,6 +99,20 @@
|
||||
</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>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<div class="col-sm-4">
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Incompatible" %}</button>
|
||||
{% elif plugin.restricted and not request.user.is_superuser %}
|
||||
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Not available" %}</button>
|
||||
{% elif plugin.module in plugins_active %}
|
||||
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button>
|
||||
{% else %}
|
||||
@@ -42,6 +44,11 @@
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p>{{ plugin.description }}</p>
|
||||
{% if plugin.restricted and not request.user.is_superuser %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plugin.app.compatibility_errors %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This plugin cannot be enabled for the following reasons:" %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% bootstrap_field form.slug layout="horizontal" %}
|
||||
{% bootstrap_field form.date_from layout="horizontal" %}
|
||||
{% bootstrap_field form.date_to layout="horizontal" %}
|
||||
{% bootstrap_field form.location layout="horizontal" %}
|
||||
{% bootstrap_field form.currency layout="horizontal" %}
|
||||
{% bootstrap_field form.is_public layout="horizontal" %}
|
||||
</fieldset>
|
||||
@@ -24,6 +25,7 @@
|
||||
{% bootstrap_field sform.contact_mail layout="horizontal" %}
|
||||
{% bootstrap_field sform.imprint_url layout="horizontal" %}
|
||||
{% bootstrap_field sform.show_quota_left layout="horizontal" %}
|
||||
{% bootstrap_field sform.display_net_prices layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Timeline" %}</legend>
|
||||
@@ -41,6 +43,12 @@
|
||||
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Waiting list" %}</legend>
|
||||
{% bootstrap_field sform.waiting_list_enabled layout="horizontal" %}
|
||||
{% bootstrap_field sform.waiting_list_auto layout="horizontal" %}
|
||||
{% bootstrap_field sform.waiting_list_hours layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{% bootstrap_field form.slug layout="horizontal" %}
|
||||
{% bootstrap_field form.date_from layout="horizontal" %}
|
||||
{% bootstrap_field form.date_to layout="horizontal" %}
|
||||
{% bootstrap_field form.location layout="horizontal" %}
|
||||
{% bootstrap_field form.currency layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{% load staticfiles %}
|
||||
{% load i18n %}
|
||||
<ul class="list-group">
|
||||
{% for log in obj.all_logentries %}
|
||||
<li class="list-group-item logentry">
|
||||
@@ -5,6 +7,11 @@
|
||||
<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." %}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.copy_from layout="horizontal" %}
|
||||
{% bootstrap_field form.has_variations layout="horizontal" %}
|
||||
{% bootstrap_field form.admission layout="horizontal" %}
|
||||
</fieldset>
|
||||
|
||||
@@ -8,23 +8,25 @@
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-lg-10">
|
||||
<div class="col-xs-12{% if category %} col-lg-10{% endif %}">
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.description layout="horizontal" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-xs-12 col-lg-2">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Category history" %}
|
||||
</h3>
|
||||
{% if category %}
|
||||
<div class="col-xs-12 col-lg-2">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Category history" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=category %}
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=category %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
{% if position.form.operation.value == "price" %}checked="checked"{% endif %}>
|
||||
{% trans "Change price to" %}
|
||||
{% bootstrap_field position.form.price layout='inline' %}
|
||||
{% if request.event.settings.display_net_prices %}
|
||||
<em>{% trans "Enter a gross price including taxes." %}</em>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<div class="panel panel-default items">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
{% if order.status == "n" and request.eventperm.can_change_orders %}
|
||||
{% if order.changable and request.eventperm.can_change_orders %}
|
||||
<a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change products" %}
|
||||
@@ -189,12 +189,20 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 price">
|
||||
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<strong>{{ event.currency }} {{ line.net_price|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
|
||||
{% if line.tax_rate %}
|
||||
<br /><small>{% blocktrans trimmed with rate=line.tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
@@ -206,17 +214,47 @@
|
||||
<strong>{% trans "Payment method fee" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
|
||||
{% if order.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% if event.settings.display_net_prices %}
|
||||
<strong>{{ event.currency }} {{ order.payment_fee_net|floatformat:2 }}</strong>
|
||||
{% if order.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
|
||||
<strong>plus</strong> {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<strong>{{ event.currency }} {{ items.payment_fee|floatformat:2 }}</strong>
|
||||
{% if order.payment_fee_tax_rate %}
|
||||
<br/>
|
||||
<small>{% blocktrans trimmed with rate=order.payment_fee_tax_rate %}
|
||||
incl. {{ rate }}% taxes
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.settings.display_net_prices %}
|
||||
<div class="row-fluid product-row total">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{% trans "Net total" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
{{ event.currency }} {{ items.net_total|floatformat:2 }}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="row-fluid product-row">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{% trans "Taxes" %}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
|
||||
{{ event.currency }} {{ items.tax_total|floatformat:2 }}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row-fluid product-row total">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{% trans "Total" %}</strong>
|
||||
|
||||
@@ -6,15 +6,32 @@
|
||||
<h1>
|
||||
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
|
||||
class="btn btn-default">
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</h1>
|
||||
<div class="row">
|
||||
<div class="{% if request.orgaperm.can_change_permissions %}col-lg-6{% endif %} col-xs-12">
|
||||
<fieldset>
|
||||
<legend>{% trans "Events" %}</legend>
|
||||
<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>
|
||||
@@ -31,7 +48,8 @@
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
<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>
|
||||
@@ -43,24 +61,25 @@
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new event" %}
|
||||
</a>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<div class="col-lg-6 col-xs-12">
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Team" %}</legend>
|
||||
<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>
|
||||
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.
|
||||
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 }}
|
||||
@@ -84,7 +103,7 @@
|
||||
{% else %}
|
||||
{{ form.instance.invite_email }}
|
||||
<span class="fa fa-envelope-o" data-toggle="tooltip"
|
||||
title="{% trans "invited, pending response" %}"></span>
|
||||
title="{% trans "invited, pending response" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ form.can_create_events }}</td>
|
||||
@@ -98,9 +117,9 @@
|
||||
<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.
|
||||
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>
|
||||
|
||||
@@ -125,9 +144,16 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="form-group">
|
||||
<div class="col-md-7 col-sm-12 col-md-offset-3">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control input-xs"
|
||||
<input type="number" class="form-control input-xs"
|
||||
id="voucher-bulk-codes-num"
|
||||
placeholder="{% trans "Number" %}">
|
||||
<div class="input-group-btn">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% block title %}{% trans "Waiting list" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Waiting list" %}</h1>
|
||||
{% if not request.event.settings.waiting_list_enabled %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The waiting list is disabled, so if the event is sold out, people cannot add themselves to this list. If you want to enable it, go to the event settings." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% if request.eventperm.can_change_orders %}
|
||||
<form method="post" class="col-md-6"
|
||||
action="{% url "control:event.orders.waitinglist.auto" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
data-asynctask>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Send vouchers" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% csrf_token %}
|
||||
{% if request.event.settings.waiting_list_auto %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You have configured that vouchers will automatically be sent to the persons on this list who waited
|
||||
the longest as soon as capacity becomes available. It might take up to half an hour for the
|
||||
vouchers to be sent after the capacity is available, so don't worry if entries do not disappear
|
||||
here immediately. If you want, you can also send them out manually right now.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You have configured that vouchers will <strong>not</strong> be sent automatically.
|
||||
You can either send them one-by-one in an order of your choice by clicking the
|
||||
buttons next to a line in this table (if sufficient quota is available) or you can
|
||||
press the big button below this text to send out as many vouchers as currently
|
||||
possible to the persons who waitet longest.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<button class="btn btn-large btn-primary" type="submit">
|
||||
{% trans "Send as many vouchers as possible" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="{% if request.eventperm.can_change_orders %}col-md-6{% else %}col-md-12{% endif %}">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
{% trans "Sales estimate" %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% blocktrans trimmed with amount=estimate|default:0|floatformat:2 currency=request.event.currency %}
|
||||
If you can make enough room at your event to fit all the persons on the waiting list in, you
|
||||
could sell tickets worth an additional <strong>{{ amount }} {{ currency }}</strong>.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<select name="status" class="form-control">
|
||||
<option value="a"
|
||||
{% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "All entries" %}</option>
|
||||
<option value="w"
|
||||
{% if request.GET.status == "w" or not request.GET.status %}selected="selected"{% endif %}>{% trans "Waiting" %}</option>
|
||||
<option value="s"
|
||||
{% if request.GET.status == "s" %}selected="selected"{% endif %}>{% trans "Voucher assigned" %}</option>
|
||||
</select>
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
{% for item in items %}
|
||||
<option value="{{ item.id }}"
|
||||
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
|
||||
{{ item.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
|
||||
</form>
|
||||
</p>
|
||||
<form method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th>{% trans "On the list since" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Voucher" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>{{ e.email }}</td>
|
||||
<td>
|
||||
{{ e.item }}
|
||||
{% if e.variation %}
|
||||
– {{ e.variation }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ e.created|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>
|
||||
{% if e.voucher %}
|
||||
<span class="label label-success">{% trans "Voucher assigned" %}</span>
|
||||
{% elif e.availability.0 == 100 %}
|
||||
<span class="label label-warning">
|
||||
{% blocktrans with num=e.availability.1 %}
|
||||
Waiting, product {{ num }}x available
|
||||
{% endblocktrans %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="label label-danger">{% trans "Waiting, product unavailable" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if e.voucher %}
|
||||
<a href="{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=e.voucher.pk %}">
|
||||
{{ e.voucher }}
|
||||
</a>
|
||||
{% elif not e.voucher and e.availability.0 == 100 %}
|
||||
<button name="assign" value="{{ e.pk }}" class="btn btn-default btn-xs">
|
||||
{% trans "Send a voucher" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@ from django.conf.urls import include, url
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, dashboards, event, global_settings, help, item, main, orders,
|
||||
organizer, user, vouchers,
|
||||
organizer, user, vouchers, waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -121,6 +121,8 @@ urlpatterns = [
|
||||
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
|
||||
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
|
||||
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
|
||||
url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'),
|
||||
])),
|
||||
url(r'^help/(?P<topic>[a-zA-Z0-9_/]+)$', help.HelpView.as_view(), name='help'),
|
||||
]
|
||||
|
||||
@@ -12,7 +12,9 @@ from django.utils import formats
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, Order, OrderPosition, Voucher
|
||||
from pretix.base.models import (
|
||||
Event, Item, Order, OrderPosition, Voucher, WaitingListEntry,
|
||||
)
|
||||
from pretix.control.signals import (
|
||||
event_dashboard_widgets, user_dashboard_widgets,
|
||||
)
|
||||
@@ -85,9 +87,53 @@ def base_widgets(sender, **kwargs):
|
||||
]
|
||||
|
||||
|
||||
@receiver(signal=event_dashboard_widgets)
|
||||
def waitinglist_widgets(sender, **kwargs):
|
||||
widgets = []
|
||||
|
||||
wles = WaitingListEntry.objects.filter(event=sender, voucher__isnull=True)
|
||||
if wles.count():
|
||||
quota_cache = {}
|
||||
itemvar_cache = {}
|
||||
happy = 0
|
||||
|
||||
for wle in wles:
|
||||
if (wle.item, wle.variation) not in itemvar_cache:
|
||||
itemvar_cache[(wle.item, wle.variation)] = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
row = itemvar_cache.get((wle.item, wle.variation))
|
||||
if row[1] > 0:
|
||||
itemvar_cache[(wle.item, wle.variation)] = (row[0], row[1] - 1)
|
||||
happy += 1
|
||||
|
||||
widgets.append({
|
||||
'content': NUM_WIDGET.format(num=str(happy), text=_('available to give to people on waiting list')),
|
||||
'priority': 50,
|
||||
'url': reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': sender.slug,
|
||||
'organizer': sender.organizer.slug,
|
||||
})
|
||||
})
|
||||
widgets.append({
|
||||
'content': NUM_WIDGET.format(num=str(wles.count()), text=_('total waiting list length')),
|
||||
'display_size': 'small',
|
||||
'priority': 50,
|
||||
'url': reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': sender.slug,
|
||||
'organizer': sender.organizer.slug,
|
||||
})
|
||||
})
|
||||
|
||||
return widgets
|
||||
|
||||
|
||||
@receiver(signal=event_dashboard_widgets)
|
||||
def quota_widgets(sender, **kwargs):
|
||||
widgets = []
|
||||
|
||||
for q in sender.quotas.all():
|
||||
status, left = q.availability()
|
||||
widgets.append({
|
||||
|
||||
@@ -132,6 +132,9 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
|
||||
if key.startswith("plugin:"):
|
||||
module = key.split(":")[1]
|
||||
if value == "enable" and module in plugins_available:
|
||||
if getattr(plugins_available[module], 'restricted', False):
|
||||
if not request.user.is_superuser:
|
||||
continue
|
||||
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
|
||||
data={'plugin': module})
|
||||
if module not in plugins_active:
|
||||
|
||||
@@ -607,24 +607,33 @@ class QuotaView(ChartContainingView, DetailView):
|
||||
data = [
|
||||
{
|
||||
'label': ugettext('Paid orders'),
|
||||
'value': self.object.count_paid_orders()
|
||||
'value': self.object.count_paid_orders(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Pending orders'),
|
||||
'value': self.object.count_pending_orders()
|
||||
'value': self.object.count_pending_orders(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Vouchers'),
|
||||
'value': self.object.count_blocking_vouchers()
|
||||
'value': self.object.count_blocking_vouchers(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Current user\'s carts'),
|
||||
'value': self.object.count_in_cart()
|
||||
}
|
||||
'value': self.object.count_in_cart(),
|
||||
'sum': True,
|
||||
},
|
||||
{
|
||||
'label': ugettext('Waiting list'),
|
||||
'value': self.object.count_waiting_list_pending(),
|
||||
'sum': False,
|
||||
},
|
||||
]
|
||||
ctx['quota_table_rows'] = list(data)
|
||||
|
||||
sum_values = sum([d['value'] for d in data])
|
||||
sum_values = sum([d['value'] for d in data if d['sum']])
|
||||
|
||||
if self.object.size is not None:
|
||||
data.append({
|
||||
@@ -757,6 +766,16 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
if form.cleaned_data['copy_from']:
|
||||
form.instance.category = form.cleaned_data['copy_from'].category
|
||||
form.instance.description = form.cleaned_data['copy_from'].description
|
||||
form.instance.active = form.cleaned_data['copy_from'].active
|
||||
form.instance.available_from = form.cleaned_data['copy_from'].available_from
|
||||
form.instance.available_until = form.cleaned_data['copy_from'].available_until
|
||||
form.instance.require_voucher = form.cleaned_data['copy_from'].require_voucher
|
||||
form.instance.hide_without_voucher = form.cleaned_data['copy_from'].hide_without_voucher
|
||||
form.instance.allow_cancel = form.cleaned_data['copy_from'].allow_cancel
|
||||
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.event.item.added', user=self.request.user, data={
|
||||
k: (form.cleaned_data.get(k).name
|
||||
|
||||
@@ -20,11 +20,16 @@ class EventList(ListView):
|
||||
template_name = 'pretixcontrol/events/index.html'
|
||||
|
||||
def get_queryset(self):
|
||||
return Event.objects.filter(
|
||||
permitted__id__exact=self.request.user.pk
|
||||
).select_related("organizer").prefetch_related(
|
||||
"setting_objects", "organizer__setting_objects"
|
||||
)
|
||||
if self.request.user.is_superuser:
|
||||
return Event.objects.all().select_related("organizer").prefetch_related(
|
||||
"setting_objects", "organizer__setting_objects"
|
||||
)
|
||||
else:
|
||||
return Event.objects.filter(
|
||||
permitted__id__exact=self.request.user.pk
|
||||
).select_related("organizer").prefetch_related(
|
||||
"setting_objects", "organizer__setting_objects"
|
||||
)
|
||||
|
||||
|
||||
def condition_copy(wizard):
|
||||
|
||||
@@ -12,8 +12,8 @@ from django.views.generic import DetailView, ListView, TemplateView, View
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, CachedTicket, Invoice, Item, ItemVariation, Order, Quota,
|
||||
generate_position_secret, generate_secret,
|
||||
CachedFile, CachedTicket, Invoice, InvoiceAddress, Item, ItemVariation,
|
||||
Order, Quota, generate_position_secret, generate_secret,
|
||||
)
|
||||
from pretix.base.services.export import export
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -179,6 +179,8 @@ class OrderDetail(OrderView):
|
||||
'raw': cartpos,
|
||||
'total': self.object.total,
|
||||
'payment_fee': self.object.payment_fee,
|
||||
'net_total': self.object.net_total,
|
||||
'tax_total': self.object.tax_total,
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +325,12 @@ class OrderResendLink(OrderView):
|
||||
def post(self, *args, **kwargs):
|
||||
with language(self.order.locale):
|
||||
try:
|
||||
try:
|
||||
invoice_name = self.order.invoice_address.name
|
||||
invoice_company = self.order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
mail(
|
||||
self.order.email, _('Your order: %(code)s') % {'code': self.order.code},
|
||||
self.order.event.settings.mail_text_resend_link,
|
||||
@@ -332,6 +340,8 @@ class OrderResendLink(OrderView):
|
||||
'order': self.order.code,
|
||||
'secret': self.order.secret
|
||||
}),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
self.order.event, locale=self.order.locale
|
||||
)
|
||||
@@ -438,8 +448,8 @@ class OrderChange(OrderView):
|
||||
template_name = 'pretixcontrol/order/change.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if self.order.status != Order.STATUS_PENDING:
|
||||
messages.error(self.request, _('This action is only allowed for pending orders.'))
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
messages.error(self.request, _('This action is only allowed for pending or paid orders.'))
|
||||
return self._redirect_back()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pretix.base.models import Organizer, OrganizerPermission, User
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.control.forms.organizer import OrganizerForm, OrganizerUpdateForm
|
||||
from pretix.control.permissions import OrganizerPermissionRequiredMixin
|
||||
from pretix.control.signals import organizer_edit_tabs
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@@ -60,20 +61,38 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
form=OrganizerPermissionForm,
|
||||
can_delete=True, can_order=False, extra=0
|
||||
)
|
||||
return fs(data=self.request.POST if self.request.method == "POST" else None,
|
||||
prefix="formset",
|
||||
queryset=OrganizerPermission.objects.filter(organizer=self.request.organizer))
|
||||
return fs(
|
||||
data=(
|
||||
self.request.POST
|
||||
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
|
||||
else None
|
||||
),
|
||||
prefix="formset",
|
||||
queryset=OrganizerPermission.objects.filter(organizer=self.request.organizer)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def add_form(self):
|
||||
return OrganizerPermissionCreateForm(data=self.request.POST if self.request.method == "POST" else None,
|
||||
prefix="add")
|
||||
return OrganizerPermissionCreateForm(
|
||||
data=(
|
||||
self.request.POST
|
||||
if self.request.method == "POST" and 'formset-TOTAL_FORMS' in self.request.POST
|
||||
else None
|
||||
),
|
||||
prefix="add"
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['formset'] = self.formset
|
||||
ctx['add_form'] = self.add_form
|
||||
ctx['events'] = self.request.organizer.events.all()
|
||||
ctx['tabs'] = []
|
||||
|
||||
for recv, retv in organizer_edit_tabs.send(sender=self.request.organizer, request=self.request,
|
||||
organizer=self.request.organizer):
|
||||
ctx['tabs'].append(retv)
|
||||
|
||||
return ctx
|
||||
|
||||
def _send_invite(self, instance):
|
||||
@@ -100,6 +119,9 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
if not self.request.orgaperm.can_change_permissions:
|
||||
raise PermissionDenied(_("You have no permission to do this."))
|
||||
|
||||
if 'formset-TOTAL_FORMS' not in self.request.POST:
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
if self.formset.is_valid() and self.add_form.is_valid():
|
||||
if self.add_form.has_changed():
|
||||
logdata = {
|
||||
|
||||
127
src/pretix/control/views/waitinglist.py
Normal file
127
src/pretix/control/views/waitinglist.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django.contrib import messages
|
||||
from django.db.models import Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.models import Item, WaitingListEntry
|
||||
from pretix.base.models.waitinglist import WaitingListException
|
||||
from pretix.base.services.waitinglist import assign_automatically
|
||||
from pretix.base.views.async import AsyncAction
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
|
||||
|
||||
class AutoAssign(EventPermissionRequiredMixin, AsyncAction, View):
|
||||
task = assign_automatically
|
||||
known_errortypes = ['WaitingListError']
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def get_success_message(self, value):
|
||||
return _('{num} vouchers have been created and sent out via email.').format(num=value)
|
||||
|
||||
def get_success_url(self, value):
|
||||
return self.get_error_url()
|
||||
|
||||
def get_error_url(self):
|
||||
return reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.do(self.request.event.id, self.request.user.id)
|
||||
|
||||
|
||||
class WaitingListView(EventPermissionRequiredMixin, ListView):
|
||||
model = WaitingListEntry
|
||||
context_object_name = 'entries'
|
||||
paginate_by = 30
|
||||
template_name = 'pretixcontrol/waitinglist/index.html'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if 'assign' in request.POST:
|
||||
if not request.eventperm.can_change_orders:
|
||||
messages.error(request, _('You do not have permission to do this'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
try:
|
||||
wle = WaitingListEntry.objects.get(
|
||||
pk=request.POST.get('assign'), event=self.request.event,
|
||||
)
|
||||
try:
|
||||
wle.send_voucher(user=request.user)
|
||||
except WaitingListException as e:
|
||||
messages.error(request, str(e))
|
||||
else:
|
||||
messages.success(request, _('An email containing a voucher code has been sent to the '
|
||||
'specified address.'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
except WaitingListEntry.DoesNotExist:
|
||||
messages.error(request, _('Waiting list entry not found.'))
|
||||
return redirect(reverse('control:event.orders.waitinglist', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug
|
||||
}))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=self.request.event
|
||||
).select_related('item', 'variation', 'voucher').prefetch_related('item__quotas', 'variation__quotas')
|
||||
|
||||
s = self.request.GET.get("status", "")
|
||||
if s == 's':
|
||||
qs = qs.filter(voucher__isnull=False)
|
||||
elif s == 'a':
|
||||
pass
|
||||
else:
|
||||
qs = qs.filter(voucher__isnull=True)
|
||||
|
||||
if self.request.GET.get("item", "") != "":
|
||||
i = self.request.GET.get("item", "")
|
||||
qs = qs.filter(item_id__in=(i,))
|
||||
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['items'] = Item.objects.filter(event=self.request.event)
|
||||
ctx['filtered'] = ("status" in self.request.GET or "item" in self.request.GET)
|
||||
|
||||
itemvar_cache = {}
|
||||
quota_cache = {}
|
||||
any_avail = False
|
||||
for wle in ctx[self.context_object_name]:
|
||||
if (wle.item, wle.variation) in itemvar_cache:
|
||||
wle.availability = itemvar_cache.get((wle.item, wle.variation))
|
||||
else:
|
||||
wle.availability = (
|
||||
wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
if wle.variation
|
||||
else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache)
|
||||
)
|
||||
itemvar_cache[(wle.item, wle.variation)] = wle.availability
|
||||
if wle.availability[0] == 100:
|
||||
any_avail = True
|
||||
|
||||
ctx['any_avail'] = any_avail
|
||||
ctx['estimate'] = self.get_sales_estimate()
|
||||
return ctx
|
||||
|
||||
def get_sales_estimate(self):
|
||||
qs = WaitingListEntry.objects.filter(
|
||||
event=self.request.event, voucher__isnull=True
|
||||
).aggregate(
|
||||
s=Sum(
|
||||
Coalesce('variation__default_price', 'item__default_price')
|
||||
)
|
||||
)
|
||||
return qs['s']
|
||||
@@ -1,6 +1,7 @@
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.conf import settings
|
||||
from django.db import connection, transaction
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
@@ -26,3 +27,40 @@ def rolledback_transaction():
|
||||
pass
|
||||
else:
|
||||
raise Exception('Invalid state, should have rolled back.')
|
||||
|
||||
|
||||
if 'mysql' in settings.DATABASES['default']['ENGINE'] and settings.DATABASE_IS_GALERA:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def casual_reads():
|
||||
"""
|
||||
When pretix runs with a MySQL galera cluster as a database backend, we can run into the
|
||||
following problem:
|
||||
|
||||
* A celery thread starts a transaction, creates an object and commits the transaction.
|
||||
It then returns the object ID into celery's result store (e.g. redis)
|
||||
|
||||
* A web thread pulls the object ID from the result store, but cannot access the object
|
||||
yet as the transaction is not yet committed everywhere.
|
||||
|
||||
This sets the wsrep_sync_wait variable to deal with this problem.
|
||||
|
||||
See also:
|
||||
|
||||
* https://mariadb.com/kb/en/mariadb/galera-cluster-system-variables/#wsrep_sync_wait
|
||||
|
||||
* https://www.percona.com/doc/percona-xtradb-cluster/5.6/wsrep-system-index.html#wsrep_sync_wait
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET @wsrep_sync_wait_orig = @@wsrep_sync_wait;")
|
||||
cursor.execute("SET SESSION wsrep_sync_wait = GREATEST(@wsrep_sync_wait_orig, 1);")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cursor.execute("SET SESSION wsrep_sync_wait = @wsrep_sync_wait_orig;")
|
||||
|
||||
else:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def casual_reads():
|
||||
yield
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-01-07 12:08+0000\n"
|
||||
"POT-Creation-Date: 2017-03-01 20:23+0000\n"
|
||||
"PO-Revision-Date: 2017-01-01 20:40+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
@@ -46,6 +46,64 @@ msgstr "Gesamtumsatz"
|
||||
msgid "Contacting Stripe …"
|
||||
msgstr "Kontaktiere Stripe …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:64
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
"browser and try again."
|
||||
msgstr ""
|
||||
"Ihre Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
|
||||
"dies länger als zwei Minuten dauert, kontaktieren Sie uns bitte oder gehen "
|
||||
"Sie in Ihrem Browser einen Schritt zurück und versuchen es erneut."
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:40
|
||||
#: pretix/static/pretixbase/js/asynctask.js:43
|
||||
#: pretix/static/pretixbase/js/asynctask.js:85
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:53
|
||||
#: pretix/static/pretixbase/js/asynctask.js:103
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten Ihre Anfrage …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:104
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
"page and try again."
|
||||
msgstr ""
|
||||
"Ihre Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
|
||||
"dauert, prüfen Sie bitte Ihre Internetverbindung. Danach können Sie diese "
|
||||
"Seite neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:46
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:76
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:88
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuchen Sie es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:140
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/clipboard.js:23
|
||||
msgid "Copied!"
|
||||
msgstr "Kopiert!"
|
||||
@@ -54,11 +112,6 @@ msgstr "Kopiert!"
|
||||
msgid "Press Ctrl-C to copy!"
|
||||
msgstr "Drücken Sie Strg+C zum Kopieren!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:99
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:43
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
@@ -71,59 +124,6 @@ msgstr "Sonstige"
|
||||
msgid "Count"
|
||||
msgstr "Anzahl"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:27
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:27
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:64
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
"browser and try again."
|
||||
msgstr ""
|
||||
"Ihre Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
|
||||
"dies länger als zwei Minuten dauert, kontaktieren Sie uns bitte oder gehen "
|
||||
"Sie in Ihrem Browser einen Schritt zurück und versuchen es erneut."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:40
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:43
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:85
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:53
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:103
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten deine Anfrage …"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:54
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:104
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
"page and try again."
|
||||
msgstr ""
|
||||
"Ihre Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
|
||||
"dauert, prüfen Sie bitte Ihre Internetverbindung. Danach können Sie diese "
|
||||
"Seite neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:46
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:76
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:88
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuchen Sie es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:10
|
||||
msgid "The items in your cart are no longer reserved for you."
|
||||
msgstr "Die Produkte in Ihrem Warenkorb sind nicht mehr für Sie reserviert."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-01-07 12:08+0000\n"
|
||||
"PO-Revision-Date: 2017-01-01 20:41+0100\n"
|
||||
"POT-Creation-Date: 2017-03-01 20:23+0000\n"
|
||||
"PO-Revision-Date: 2017-01-18 09:42+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
@@ -46,6 +46,64 @@ msgstr "Gesamtumsatz"
|
||||
msgid "Contacting Stripe …"
|
||||
msgstr "Kontaktiere Stripe …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:64
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
"browser and try again."
|
||||
msgstr ""
|
||||
"Deine Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
|
||||
"dies länger als zwei Minuten dauert, kontaktiere uns bitte oder gehe in "
|
||||
"deinem Browser einen Schritt zurück und versuche es erneut."
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:40
|
||||
#: pretix/static/pretixbase/js/asynctask.js:43
|
||||
#: pretix/static/pretixbase/js/asynctask.js:85
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:53
|
||||
#: pretix/static/pretixbase/js/asynctask.js:103
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten deine Anfrage …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:104
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
"page and try again."
|
||||
msgstr ""
|
||||
"Deine Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
|
||||
"dauert, prüfe bitte deine Internetverbindung. Danach kannst du diese Seite "
|
||||
"neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:46
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:76
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:88
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:140
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/clipboard.js:23
|
||||
msgid "Copied!"
|
||||
msgstr "Kopiert!"
|
||||
@@ -54,11 +112,6 @@ msgstr "Kopiert!"
|
||||
msgid "Press Ctrl-C to copy!"
|
||||
msgstr "Drücke Strg+C zum kopieren!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:99
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:43
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
@@ -71,59 +124,6 @@ msgstr "Sonstige"
|
||||
msgid "Count"
|
||||
msgstr "Anzahl"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:27
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:27
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:64
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
"browser and try again."
|
||||
msgstr ""
|
||||
"Deine Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Wenn "
|
||||
"dies länger als zwei Minuten dauert, kontaktiere uns bitte oder gehe in "
|
||||
"deinem Browser einen Schritt zurück und versuche es erneut."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:40
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:43
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:85
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:53
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:103
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten deine Anfrage …"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asyncdownload.js:54
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:104
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
"page and try again."
|
||||
msgstr ""
|
||||
"Deine Anfrage wird an den Server gesendet. Wenn dies länger als eine Minute "
|
||||
"dauert, prüfe bitte deine Internetverbindung. Danach kannst du diese Seite "
|
||||
"neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:46
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:76
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/asynctask.js:88
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:10
|
||||
msgid "The items in your cart are no longer reserved for you."
|
||||
msgstr "Die Produkte in deinem Warenkorb sind nicht mehr für dich reserviert."
|
||||
|
||||
@@ -3,8 +3,9 @@ from collections import OrderedDict
|
||||
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nFormField, I18nTextarea
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import I18nFormField, I18nTextarea, LazyI18nString
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
|
||||
|
||||
|
||||
@@ -2,36 +2,45 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
|
||||
{% if no_more_payments %}
|
||||
<div class="alert alert-danger">
|
||||
{% blocktrans trimmed with date=request.event.settings.payment_term_last|date:"SHORT_DATE_FORMAT" %}
|
||||
In the payment settings of your event, you set the {{ date }} as the last date of any payments.
|
||||
Therefore, you won't be able to mark any order as paid here.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>{% blocktrans trimmed %}
|
||||
This page allows you to upload bank statement files to process incoming payments.
|
||||
{% endblocktrans %}</p>
|
||||
<p>{% blocktrans trimmed %}
|
||||
Currently, this feature supports <code>.csv</code> files and files in the MT940 format.
|
||||
{% endblocktrans %}</p>
|
||||
{% if job_running %}
|
||||
<div class="alert alert-info" data-job-waiting data-job-waiting-url="{% url "plugins:banktransfer:import.job" event=request.event.slug organizer=request.event.organizer.slug job=job_running.pk %}?ajax=1">
|
||||
<span class="fa fa-cog fa-spin"></span>
|
||||
{% trans "An import is currently being processed, please try again in a few minutes." %}
|
||||
</div>
|
||||
{% else %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form-inline">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>{% blocktrans trimmed %}
|
||||
This page allows you to upload bank statement files to process incoming payments.
|
||||
{% endblocktrans %}</p>
|
||||
<p>{% blocktrans trimmed %}
|
||||
Currently, this feature supports <code>.csv</code> files and files in the MT940 format.
|
||||
{% endblocktrans %}</p>
|
||||
{% if job_running %}
|
||||
<div class="alert alert-info" data-job-waiting data-job-waiting-url="{% url "plugins:banktransfer:import.job" event=request.event.slug organizer=request.event.organizer.slug job=job_running.pk %}?ajax=1">
|
||||
<span class="fa fa-cog fa-spin"></span>
|
||||
{% trans "An import is currently being processed, please try again in a few minutes." %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<button class="btn btn-primary pull-right" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start upload" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form-inline">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<button class="btn btn-primary pull-right" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start upload" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if transactions_unhandled|length > 0 or request.GET.search %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
|
||||
@@ -2,7 +2,6 @@ import csv
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from locale import format as lformat
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import reverse
|
||||
@@ -135,6 +134,8 @@ class ActionView(EventPermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
from django.utils.formats import localize
|
||||
|
||||
query = request.GET.get('query', '')
|
||||
if len(query) < 2:
|
||||
return JsonResponse({'results': []})
|
||||
@@ -145,7 +146,7 @@ class ActionView(EventPermissionRequiredMixin, View):
|
||||
{
|
||||
'code': o.code,
|
||||
'status': o.get_status_display(),
|
||||
'total': lformat("%.2f", o.total) + ' ' + self.request.event.currency
|
||||
'total': localize(o.total) + ' ' + self.request.event.currency
|
||||
} for o in qs
|
||||
]
|
||||
})
|
||||
@@ -354,4 +355,8 @@ class ImportView(EventPermissionRequiredMixin, ListView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['job_running'] = self.job_running
|
||||
ctx['no_more_payments'] = False
|
||||
if self.request.event.settings.get('payment_term_last'):
|
||||
if now() > self.request.event.payment_term_last:
|
||||
ctx['no_more_payments'] = True
|
||||
return ctx
|
||||
|
||||
@@ -8,8 +8,8 @@ from django.contrib import messages
|
||||
from django.template.loader import get_template
|
||||
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Quota, RequiredAction
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.models import Order, Quota, RequiredAction
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
@@ -145,47 +145,48 @@ class Paypal(BasePaymentProvider):
|
||||
"""
|
||||
if (request.session.get('payment_paypal_id', '') == ''
|
||||
or request.session.get('payment_paypal_payer', '') == ''):
|
||||
messages.error(request, _('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
|
||||
self.init_api()
|
||||
payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
|
||||
if str(payment.transactions[0].amount.total) != str(order.total) or payment.transactions[0].amount.currency != \
|
||||
self.event.currency:
|
||||
messages.error(request, _('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
logger.error('Value mismatch: Order %s vs payment %s' % (order.id, str(payment)))
|
||||
return
|
||||
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
|
||||
return self._execute_payment(payment, request, order)
|
||||
|
||||
def _execute_payment(self, payment, request, order):
|
||||
payment.replace([
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/transactions/0/item_list",
|
||||
"value": {
|
||||
"items": [
|
||||
{
|
||||
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
|
||||
"quantity": 1,
|
||||
"price": str(order.total),
|
||||
"currency": order.event.currency
|
||||
}
|
||||
]
|
||||
if payment.state == 'created':
|
||||
payment.replace([
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/transactions/0/item_list",
|
||||
"value": {
|
||||
"items": [
|
||||
{
|
||||
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
|
||||
"quantity": 1,
|
||||
"price": str(order.total),
|
||||
"currency": order.event.currency
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/transactions/0/description",
|
||||
"value": __('Order {order} for {event}').format(
|
||||
event=request.event.name,
|
||||
order=order.code
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/transactions/0/description",
|
||||
"value": __('Order {order} for {event}').format(
|
||||
event=request.event.name,
|
||||
order=order.code
|
||||
)
|
||||
}
|
||||
])
|
||||
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
|
||||
])
|
||||
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
|
||||
|
||||
order.refresh_from_db()
|
||||
if payment.state == 'pending':
|
||||
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
|
||||
'payment completed.'))
|
||||
@@ -194,21 +195,26 @@ class Paypal(BasePaymentProvider):
|
||||
return
|
||||
|
||||
if payment.state != 'approved':
|
||||
messages.error(request, _('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
logger.error('Invalid state: %s' % str(payment))
|
||||
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
||||
'proceed.'))
|
||||
return
|
||||
|
||||
if order.status == Order.STATUS_PAID:
|
||||
logger.warning('PayPal success event even though order is already marked as paid')
|
||||
return
|
||||
|
||||
try:
|
||||
mark_order_paid(order, 'paypal', json.dumps(payment.to_dict()))
|
||||
except Quota.QuotaExceededException as e:
|
||||
messages.error(request, str(e))
|
||||
RequiredAction.objects.create(
|
||||
event=request.event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({
|
||||
'order': order.code,
|
||||
'payment': payment.id
|
||||
})
|
||||
)
|
||||
raise PaymentException(str(e))
|
||||
|
||||
except SendMailException:
|
||||
messages.warning(request, _('There was an error sending the confirmation mail.'))
|
||||
return None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user