Compare commits

...

119 Commits

Author SHA1 Message Date
Raphael Michel
b2cd633248 Update django-bootstrap3, bump version 2017-01-07 17:23:41 +01:00
Raphael Michel
0acee0e362 Get rid of User.givenname and User.familyname 2017-01-07 16:35:04 +01:00
Raphael Michel
33265d05fb Add event_live_issues signal 2017-01-07 14:41:53 +01:00
Raphael Michel
2182a4e361 Add improved UI if no event can be created 2017-01-07 14:25:01 +01:00
Raphael Michel
2336505309 Replace "Slug" with "Short form" for organizers 2017-01-07 14:24:38 +01:00
Raphael Michel
15b5e66da9 Add URL to permission test list 2017-01-07 14:13:14 +01:00
Raphael Michel
c7676cd17a Refs #39 -- Add permission editor for organizers 2017-01-07 14:10:31 +01:00
Raphael Michel
e53562dda2 Updated German translation 2017-01-07 13:14:21 +01:00
Raphael Michel
d134dcf6a9 Added team invitations 2017-01-07 13:05:36 +01:00
Raphael Michel
981d82b0ee Updated German translation 2017-01-07 11:39:55 +01:00
Raphael Michel
e75bce37bc Fix incorrect paths in Makefile 2017-01-05 13:08:40 +01:00
Raphael Michel
ef432252f0 Fix invalid URL usage 2017-01-05 12:20:58 +01:00
Raphael Michel
e9e743f312 Add more log texts 2017-01-05 12:20:18 +01:00
Raphael Michel
0998814e69 Improve session inheritation 2017-01-05 12:15:38 +01:00
Raphael Michel
d3f21353ca Allow to access not-yet-live shop on different domain 2017-01-05 12:11:50 +01:00
Raphael Michel
f6d8b825d5 Add plugin installation docs and rebuild command 2017-01-04 23:33:07 +01:00
Raphael Michel
4012658596 Add PyPI badges 2017-01-04 22:45:38 +01:00
Raphael Michel
b2eb159380 Bump version to 1.0.0b1 2017-01-04 22:12:41 +01:00
Raphael Michel
b16824ec2b Rename readme, fix packaging 2017-01-04 22:10:06 +01:00
Raphael Michel
c639cd96f5 Moved static files and celery.py 2017-01-04 22:00:28 +01:00
Raphael Michel
dd074a11d4 Fix typo in setup.py 2017-01-04 21:38:45 +01:00
Raphael Michel
41c0719235 Add correct mt940 package to requirements 2017-01-04 21:35:07 +01:00
Raphael Michel
210768a14f Fix mistyped Django version 2017-01-04 21:21:29 +01:00
Raphael Michel
87b7ffcc2a Simplify and update setup.py and requirements 2017-01-04 21:17:12 +01:00
Raphael Michel
67de7150e5 Built-in support for sentry 2017-01-04 21:04:47 +01:00
Raphael Michel
b6e42d64da Upgrade to Celery 4 2017-01-04 20:12:50 +01:00
Raphael Michel
89732a3057 Use new fork of django-formset-js 2017-01-04 19:35:55 +01:00
Raphael Michel
b23d95b6c3 Fix #74 -- Warn if quota exceeds after payment 2017-01-04 19:19:58 +01:00
Raphael Michel
847997ea9b Fix #32 -- Add a PayPal webhook listener 2017-01-04 16:45:57 +01:00
Raphael Michel
30d67cd4ad Fix incorrect placeholder in locale 2017-01-04 15:27:08 +01:00
Raphael Michel
aed9382fd7 Introduce RequiredAction model
Fix #343 by no longer marking as refunded automatically
2017-01-04 15:04:18 +01:00
Raphael Michel
871011826c Log Event.live changes 2017-01-04 14:30:55 +01:00
Raphael Michel
dd8d28f6b8 Pin html5lib version 2017-01-04 13:46:57 +01:00
Raphael Michel
9d08e23a48 Fix #306 -- Add HTML multipart version to emails 2017-01-04 13:31:40 +01:00
Raphael Michel
954af1de3d Added a log text 2017-01-04 10:54:26 +01:00
Raphael Michel
1cfce1f5e9 Update German translation 2017-01-04 00:53:11 +01:00
Raphael Michel
4dbf5dc054 Improve email history template 2017-01-04 00:43:04 +01:00
FlaviaBastos
cf334e2b48 Fix #307 -- Log sent emails 2017-01-04 00:43:04 +01:00
Raphael Michel
adbe966d85 Fixed failing test 2017-01-04 00:22:13 +01:00
Raphael Michel
aab56d3b39 Add user filter for log idsplay 2017-01-04 00:07:56 +01:00
Raphael Michel
aa2f0e0fd0 Improve log display 2017-01-03 23:58:51 +01:00
Raphael Michel
2ee0ff755d Add page that displays event logs 2017-01-03 23:46:21 +01:00
Raphael Michel
f4be14eed8 Refs #126 -- Display logs of vouchers, items, quotas and categories 2017-01-03 23:13:27 +01:00
Raphael Michel
dc73018404 Fix #135 -- Wwarn about over-booking a quota 2017-01-03 22:13:30 +01:00
Raphael Michel
4fbad2d360 Fixed a bug while editing variations 2017-01-02 21:13:44 +01:00
Raphael Michel
b19c470ce5 Update German translation 2017-01-01 20:41:19 +01:00
Raphael Michel
a0350d1444 Fix #349 -- Allow to clone an event 2017-01-01 20:35:53 +01:00
Raphael Michel
1c54ca7b74 Improve LazyI18nString logic 2017-01-01 20:32:35 +01:00
Raphael Michel
e6f731ad77 New event creation wizard 2017-01-01 19:47:02 +01:00
Raphael Michel
47fb61b762 Change Checkin.datetime to not auto_now_add 2016-12-31 16:33:34 +01:00
Tobias Kunze
4e6345aaed Remove whitespace when searching for a voucher (#366) 2016-12-29 14:11:50 +01:00
Raphael Michel
f4672564ce Fix #37 -- Clever displaying of date ranges 2016-12-25 22:51:19 +01:00
Raphael Michel
a4218fa1b9 Improve file download UX 2016-12-25 22:26:45 +01:00
Raphael Michel
c5ec918e78 Fix a PDF generation loop 2016-12-23 13:44:11 +01:00
Raphael Michel
62ef5271de Handle MultipleObjectsReturned in tickets.py 2016-12-23 13:30:50 +01:00
Raphael Michel
d698313f1d Do not allow initiating stripe/paypal payments after the last payment
date
2016-12-23 13:29:41 +01:00
Raphael Michel
16ab6e44f5 Correct position numbering 2016-12-23 11:10:51 +01:00
Raphael Michel
dddb1d4a65 Guarantee correct grouping 2016-12-23 11:09:30 +01:00
Raphael Michel
873c7dc65d Race conditions can lead to duplicate CachedTickets 2016-12-23 11:02:29 +01:00
Raphael Michel
0082216d75 Fix failing tests 2016-12-22 18:25:58 +01:00
Raphael Michel
0d19944304 Fix bug in CartMixin 2016-12-22 17:54:43 +01:00
Raphael Michel
70fa7eac6b Vouchers: Bug in export, default mode 2016-12-22 17:54:24 +01:00
Raphael Michel
3db4833290 Add checkin tick to order positions 2016-12-21 19:19:41 +01:00
Raphael Michel
5c8c106d5b Reduce query load on order detail page 2016-12-21 19:17:09 +01:00
Raphael Michel
d4aa3e62a5 pretixdroid: Add Check-Ins to log 2016-12-21 19:13:13 +01:00
Raphael Michel
888cce78a5 Rename reverse of CheckIn.position 2016-12-21 19:11:12 +01:00
Raphael Michel
d0a5529080 Fix two broken tests 2016-12-21 19:03:23 +01:00
Raphael Michel
0dc3f30791 Sort positions by ID 2016-12-21 19:03:11 +01:00
Raphael Michel
18c24623d7 Use new position ids in order change logs 2016-12-21 18:59:51 +01:00
Raphael Michel
9bf9cc0f9f Do not group positions in control view 2016-12-21 18:43:50 +01:00
Raphael Michel
77e917345c Decouple CachedTicket from CachedFile 2016-12-21 18:37:12 +01:00
Raphael Michel
ad60dadee4 Add a position number to the OrderPosition model 2016-12-21 18:11:19 +01:00
Raphael Michel
83057e48ec Only show download info if a payment provider is enabled 2016-12-21 18:04:04 +01:00
Raphael Michel
7639ef9a42 Protect against empty orders 2016-12-21 16:28:33 +01:00
Raphael Michel
852bc6c128 Avoid duplicate order position secrets 2016-12-20 14:48:41 +01:00
chotee
cfda772133 Improved documentation by adding some requirements that where needed to install from a clean Debian 8.6 installation. (#365)
Before the ```$ pip3 install -r requirements.txt -r requirements/dev.txt``` installation step would succeed I needed to add the following dependencies:
```$ sudo aptitude install libxml2-dev```
```$ sudo aptitude install libxslt1-dev```
The Error without the dependencies ended with:
```
    In file included from src/lxml/lxml.etree.c:515:0:
    src/lxml/includes/etree_defs.h:14:31: fatal error: libxml/xmlversion.h: No such file or directory
     #include "libxml/xmlversion.h"
                                   ^
    compilation terminated.
    Compile failed: command 'x86_64-linux-gnu-gcc' failed with exit status 1
    creating tmp
    cc -I/usr/include/libxml2 -c /tmp/xmlXPathInitm_7rr3ky.c -o tmp/xmlXPathInitm_7rr3ky.o
    /tmp/xmlXPathInitm_7rr3ky.c:1:26: fatal error: libxml/xpath.h: No such file or directory
     #include "libxml/xpath.h"
                              ^
    compilation terminated.
    *********************************************************************************
    Could not find function xmlCheckVersion in library libxml2. Is libxml2 installed?
    *********************************************************************************
    error: command 'x86_64-linux-gnu-gcc' failed with exit status
```

In the "make localcompile" step I had to do
```$ sudo aptitude install gettext```
before it would succeed.

There was a reference to a demo event ("/mrmcd/2015/") it should probably be /bigevents/2017/ .
2016-12-19 09:01:59 +01:00
Tobias Kunze
5e3087341c Bankimport: Order transactions by their job's date (#362)
Leads to having the most recent unresolved transfers on top instead of
in between.
2016-12-17 11:44:31 +01:00
Brandon
d1357ed5c0 Fix #359 -- Specific error message for empty bank import. (#363)
* added specific error message for no file uploaded for empty bank import

added return statements to each if/elif/else for importing bank data file

fixed styling according to flake8

* fixed if else ordering
2016-12-17 11:43:47 +01:00
Tobias Kunze
58668010a2 Remove trailing whitespaces (#361) 2016-12-16 21:44:34 +01:00
Jonas Große Sundrup
e5cb26464e use digest-compare for password-comparison (#360) 2016-12-16 21:22:05 +01:00
Raphael Michel
b098c9c16a Fix #355 -- Compatibility problems with recent (django-)libsass 2016-12-14 14:08:14 +01:00
Raphael Michel
759fed7a8b OrderExtendForm: Allow to set today 2016-12-14 13:53:00 +01:00
Raphael Michel
24da9b8d85 Update translation 2016-12-14 13:43:14 +01:00
Raphael Michel
1af09509ff Prevent deletion of ordered variations 2016-12-14 13:39:01 +01:00
Raphael Michel
7f21c171fd Only allow payment provider preview if required fields are set 2016-12-14 13:10:54 +01:00
Raphael Michel
47814900dc Fix TypeError in cached_file_delete 2016-12-14 13:10:32 +01:00
Tobias Kunze
425d6590d0 Add copy-to-clipboard for voucher bulk creation (#348)
Adds
 - clipboard.js
 - custom js initializing a Clipboard and adding a tooltip
 - a button in the bulk creation form
2016-12-12 08:36:42 +01:00
Tobias Kunze
c754966103 Display log entry for changed order secrets (#354) 2016-12-11 17:48:21 +01:00
Raphael Michel
43ca778796 pretixdroid: Added event information to the status endpoint 2016-12-11 17:26:00 +01:00
Raphael Michel
765cb09c6c Update to recent pip in Dockerfile 2016-12-10 12:09:31 +01:00
Raphael Michel
8e4eb52386 pretixdroid: add status endpoint (#351) 2016-12-08 22:38:17 +01:00
Raphael Michel
fb19891473 Add plugin documentation 2016-12-08 18:20:43 +01:00
Raphael Michel
fa0bd5e89e Moved Checkin model to pretixbase 2016-12-08 17:51:23 +01:00
Raphael Michel
3c96e631da Change recommended redis socket path to avoid systemd problems 2016-12-08 12:38:21 +01:00
Raphael Michel
d27fefe4da Improve URL parameter validation 2016-12-08 12:22:04 +01:00
Raphael Michel
8cb977e4d6 Fix broken placeholders in emails 2016-12-07 13:55:02 +01:00
Raphael Michel
7154d3f510 Fix setting a voucher price to 0 2016-12-07 11:31:37 +01:00
Raphael Michel
1785178532 Fix OrderContactChange logging 2016-12-06 13:45:27 +01:00
Raphael Michel
970734cff7 Fix DoesNotExist error 2016-12-05 13:51:45 +01:00
Raphael Michel
5ca82a922a Fix timezones in PDF reports 2016-12-05 13:37:12 +01:00
Raphael Michel
34b937273a Update PyPDF2 2016-12-04 22:53:36 +01:00
Tobias Kunze
a0e53b532a Update translations (#347) 2016-12-04 20:47:12 +01:00
Tobias Kunze
62edf1e7b8 Fix #345 -- More extensive log display when changing an order email (#346) 2016-12-02 09:31:23 +01:00
Tobias Kunze
3930fc749a Fix #179 -- New flexbox layout for dashboard (#344)
* Use flexbox for better tiling

Replaces 'width' by 'display_size' and reduces sorting complexity.

* CSS improvements, Responsiveness
2016-12-01 17:36:29 +01:00
Raphael Michel
bfd87f11dd Stripe: Mark order as paid on successful webhook call 2016-11-30 13:00:16 +01:00
Raphael Michel
f76d173162 Fix various doc issues 2016-11-29 17:19:22 +01:00
Raphael Michel
f8b38dca82 Fix #110 -- Turn off process-local cache 2016-11-29 17:19:05 +01:00
Raphael Michel
248ab25567 Fix #296 -- DST issues with expiry dates 2016-11-29 17:05:33 +01:00
Raphael Michel
982a622e88 Improve PayPal item texts 2016-11-29 17:05:07 +01:00
Raphael Michel
82b68bf7e0 Fix #304 -- Send consistent metadata to PayPal 2016-11-29 16:50:37 +01:00
Raphael Michel
2efde1669d PayPal: Refactor callback view 2016-11-29 16:33:27 +01:00
Raphael Michel
eea6a5e9da Refs #145 -- Vouchers that grant discounts 2016-11-29 16:18:07 +01:00
Raphael Michel
fdbe71ff63 Fix #141 -- Caching improvements for ticket outputs 2016-11-29 15:52:16 +01:00
Raphael Michel
a8be2d5f24 Fix a test that fails on MySQL 2016-11-29 15:17:11 +01:00
Raphael Michel
b15c4e6d6f Fixed broken test on redis 2016-11-29 15:14:08 +01:00
Raphael Michel
8b00361d1b Fix wrong sums 2016-11-28 19:36:55 +01:00
Nicole Klünder
444678ce6b fix typo in docs 2016-11-28 18:06:32 +01:00
Raphael Michel
0f8d520336 Add "regenerate secrets" option to OrderContactForm 2016-11-28 12:52:44 +01:00
Raphael Michel
4d7b5a0a3b Update German translation 2016-11-27 19:54:25 +01:00
603 changed files with 8751 additions and 2674 deletions

View File

@@ -35,6 +35,7 @@ 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

View File

@@ -1,9 +1,17 @@
pretix
======
[![Docs](https://readthedocs.org/projects/pretix/badge/?version=latest)](http://docs.pretix.eu/en/latest/)
[![Build Status](https://travis-ci.org/pretix/pretix.svg?branch=master)](https://travis-ci.org/pretix/pretix)
[![Coverage Status](https://img.shields.io/coveralls/pretix/pretix.svg)](https://coveralls.io/r/pretix/pretix)
.. image:: https://img.shields.io/pypi/v/pretix.svg
:target: https://pypi.python.org/pypi/pretix
.. image:: https://readthedocs.org/projects/pretix/badge/?version=latest
:target: https://docs.pretix.eu/en/latest/
.. 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
Reinventing ticket presales, one bit at a time.
@@ -20,12 +28,11 @@ like, but we try to keep the changes to documented APIs as small as possible. If
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.
Since very recently we now have an [installation guide](https://docs.pretix.eu/en/latest/admin/installation/index.html)
in our documentation.
Since very recently we now have an `installation guide`_ in our documentation.
Contributing
------------
If you want to contribute to pretix, please read the [developer documentation](https://docs.pretix.eu/en/latest/development/index.html)
If you want to contribute to pretix, please read the `developer documentation`_
in our documentation. If you have any further questions, please do not hesitate to ask!
License
@@ -38,4 +45,8 @@ 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](https://pretix.eu) or contact Raphael directly.
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

View File

@@ -33,7 +33,7 @@ fi
if [ "$1" == "taskworker" ]; then
export C_FORCE_ROOT=True
exec celery -A pretix worker -l info
exec celery -A pretix.celery_app worker -l info
fi
if [ "$1" == "shell" ]; then

View File

@@ -16,7 +16,9 @@ the files found before.
The file is expected to be in the INI format as specified in the `Python documentation`_.
The config file may contain the following sections (all settings are optional and have default values).
The config file may contain the following sections (all settings are optional and have
default values). We suggest that you start from the examples given in one of the
installation tutorials.
pretix settings
---------------
@@ -201,6 +203,9 @@ You can use an existing memcached server as pretix's caching backend::
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
shared memcached instance, not multiple ones, because cache invalidations would not be
propagated otherwise.
Redis
-----
@@ -238,6 +243,19 @@ RabbitMQ might be the better choice if you have a complex, multi-server, high-pe
but as you already should have a redis instance ready for session and lock storage, we recommend
redis for convenience. See the `Celery documentation`_ for more details.
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.
[sentry]
dsn=https://<key>:<secret>@sentry.io/<project>
``dsn``
You will be given this value by your sentry installation.
Secret length
-------------

View File

@@ -74,7 +74,7 @@ redis instance to be running on the same host. To avoid the hassle with network
recommend connecting to redis via a unix socket. To enable redis on unix sockets, add the following to your
``/etc/redis/redis.conf``::
unixsocket /tmp/redis.sock
unixsocket /var/run/redis/redis.sock
unixsocketperm 777
Now restart redis-server::
@@ -127,14 +127,14 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
host=172.17.0.1
[redis]
location=unix:///tmp/redis.sock?db=0
location=unix:///var/run/redis/redis.sock?db=0
; Remove the following line if you are unsure about your redis' security
; to reduce impact if redis gets compromised.
sessions=true
[celery]
backend=redis+socket:///tmp/redis.sock?virtual_host=1
broker=redis+socket:///tmp/redis.sock?virtual_host=2
backend=redis+socket:///var/run/redis/redis.sock?virtual_host=1
broker=redis+socket:///var/run/redis/redis.sock?virtual_host=2
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
@@ -160,7 +160,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
ExecStart=/usr/bin/docker run --name %n -p 8345:80 \
-v /var/pretix-data:/data \
-v /etc/pretix:/etc/pretix \
-v /tmp/redis.sock:/tmp/redis.sock \
-v /var/run/redis:/var/run/redis \
-v /var/run/mysqld:/var/run/mysqld \
pretix/standalone all
ExecStop=/usr/bin/docker stop %n
@@ -168,7 +168,7 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
[Install]
WantedBy=multi-user.target
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following comamnds
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands
to enable and start the service::
# systemctl daemon-reload
@@ -235,6 +235,26 @@ Updates are fairly simple, but require at least a short downtime::
Restarting the service can take a few seconds, especially if the update requires changes to the database.
Install a plugin
----------------
To install a plugin, you need to build your own docker image. To do so, create a new directory and place a file
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
choice)::
FROM pretix/standalone
USER root
RUN pip3 install pretix-passbook
USER pretixuser
RUN make production
Then, go to that directory and build the image::
$ docker build -t mypretix
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.
.. _Docker: https://docs.docker.com/engine/installation/linux/debian/
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/

View File

@@ -5,51 +5,33 @@ General remarks
Requirements
------------
To use pretix, the most minimal setup consists of:
To use pretix, you wull need the following things:
* **pretix** and the python packages it depends on
* An **WSGI application server** (we recommend gunicorn)
* A periodic task runner, e.g. ``cron``
To run pretix, you will need **at least Python 3.4**. We only recommend installations on **Linux**, Windows is not
officially supported (but might work).
* **A database**. This needs to be a SQL-based that is supported by Django. We highly recommend to either
go for **PostgreSQL** or **MySQL/MariaDB**. If you do not provide one, pretix will run on SQLite, which is useful
for evaluation and development purposes.
Optional requirements
---------------------
.. warning:: Do not ever use SQLite in production. It will break.
pretix is built in a way that makes many of the following requirements optional. However, performance or security might
be very low if you skip some of them, therefore they are only partly optional.
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
faster. Also, you need a proxying web server in front to provide SSL encryption.
Database
A good SQL-based database to run on that is supported by Django. We highly recommend to either go for **PostgreSQL**
or **MySQL/MariaDB**.
If you do not provide one, pretix will run on SQLite, which is useful for evaluation and development purposes.
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
.. warning:: Do not ever use SQLite in production. It will break.
* A **redis** server. This will be used for caching, session storage and task queuing.
Reverse proxy
pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix is capable of
doing this, having this handled by a proper web server like **nginx** or **Apache** will be much faster. Also, you
need a proxying web server in front to provide SSL encryption.
.. warning:: pretix can run without redis, however this is only intended for development and should never be
used in production.
.. warning:: Do not ever run without SSL in production. Your users deserve encrypted connections and thanks to
`Let's Encrypt`_ SSL certificates can be obtained for free these days.
Task worker
When pretix has to do heavy stuff, it is better to offload it into a background process instead of having the
users connection wait. Therefore pretix provides a background service that can be used to work on those
longer-running tasks.
This requires at least Redis (and optionally RabbitMQ).
Redis
If you provide a redis instance, pretix is able to make use of it in the three following ways:
* Caching
* Fast session storage
* Queuing and result storage for the task worker queue
RabbitMQ
RabbitMQ can be used as a more advanced queue manager for the task workers if necessary.
* Optionally: RabbitMQ or memcached. Both of them might provide speedups, but if they are not present,
redis will take over their job.
.. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -105,8 +105,8 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
See :ref:`email configuration <mail-settings>` to learn more about configuring mail features.
Install pretix from source
--------------------------
Install pretix from PyPI
------------------------
Now we will install pretix itself. The following steps are to be executed as the ``pretix`` user. Before we
actually install pretix, we will create a virtual environment to isolate the python packages from your global
@@ -116,14 +116,10 @@ python installation::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel
We now clone pretix and install its Python dependencies (replace ``mysql`` with ``postgres`` if you're running
PostgreSQL)::
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following
command if you're running PostgreSQL::
(venv)$ git clone https://github.com/pretix/pretix.git /var/pretix/source
(venv)$ cd /var/pretix/source/src
(venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
-r requirements/redis.txt \
-r requirements/py34.txt gunicorn
(venv)$ pip install "pretix[mysql]" gunicorn
We also need to create a data directory::
@@ -131,8 +127,8 @@ We also need to create a data directory::
Finally, we compile static files and translation data and create the database structure::
(venv)$ make production
(venv)$ python manage.py migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix migrate
Start pretix as a service
@@ -171,7 +167,7 @@ For background tasks we need a second service ``/etc/systemd/system/pretix-worke
Group=pretix
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 worker -l info
ExecStart=/var/pretix/venv/bin/celery -A pretix.celery_app worker -l info
WorkingDirectory=/var/pretix/source/src
Restart=on-failure
@@ -191,7 +187,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 && ./manage.py runperiodic
15,45 * * * * export PATH=/var/pretix/venv/bin:$PATH && cd /var/pretix/source/src && python -m pretix runperiodic
The cronjob should run as the ``pretix`` user (``crontab -e -u pretix``).
@@ -254,16 +250,27 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
``mysql`` with ``postgres`` if necessary)::
$ source /var/pretix/venv/bin/activate
(venv)$ cd /var/pretix/source/src
(venv)$ git pull origin master
(venv)$ pip3 install -r requirements.txt -r requirements/mysql.txt \
-r requirements/redis.txt \
-r requirements/py34.txt gunicorn
(venv)$ python manage.py migrate
(venv)$ make production
(venv)$ python manage.py updatestyles
(venv)$ pip3 install -U pretix[mysql] gunicorn
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
Install a plugin
----------------
To install a plugin, just use ``pip``! Depending on the plugin, you should probably apply database migrations and
rebuild the static files afterwards. Replace ``pretix-passbook`` with the plugin of your choice in the following
example::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install pretix-passbook
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
# systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/

View File

@@ -38,6 +38,7 @@ extensions = [
'sphinx.ext.doctest',
'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinxcontrib.httpdomain',
]
# Add any paths that contain templates here, relative to this directory.

View File

@@ -11,7 +11,7 @@ Core
----
.. automodule:: pretix.base.signals
:members: periodic_task
:members: periodic_task, event_live_issues
Order events
""""""""""""
@@ -51,7 +51,7 @@ Backend
.. automodule:: pretix.base.signals
:members: logentry_display
:members: logentry_display, requiredaction_display
Vouchers
""""""""

View File

@@ -70,8 +70,6 @@ The provider class
.. automethod:: is_allowed
.. automethod:: is_allowed_for_order
.. autoattribute:: payment_form_fields
.. automethod:: checkout_prepare

View File

@@ -65,5 +65,3 @@ The output class
.. automethod:: generate
.. autoattribute:: download_button_text
.. autoattribute:: download_button_icon

View File

@@ -8,12 +8,12 @@ Python code
Use `flake8`_ to check for conformance problems. The project includes a setup.cfg file
with a default configuration for flake8 that excludes migrations and other non-relevant
code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase)
and increases the maximum line length to more than 79 characters. **However** you should
code parts. It also silences a few checks, e.g. ``N802`` (function names should be lowercase)
and increases the maximum line length to more than 79 characters. **However** you should
still name all your functions lowercase [#f1]_ and keep your lines short when possible.
* Our build server will reject all code violating other flake8 checks than the following:
* E123: closing bracket does not match indentation of opening brackets line
* F403: ``from module import *`` used; unable to detect undefined names
* F401: module imported but unused

View File

@@ -29,6 +29,9 @@ Organizers and events
.. autoclass:: pretix.base.models.EventPermission
:members:
.. autoclass:: pretix.base.models.RequiredAction
:members:
Items
-----
@@ -64,6 +67,9 @@ Carts and Orders
:members:
.. autoclass:: pretix.base.models.QuestionAnswer
:members:
.. autoclass:: pretix.base.models.Checkin
:members:
Logging

View File

@@ -17,6 +17,9 @@ External Dependencies
* ``pyvenv`` for Python 3 (Debian package: ``python3-venv``)
* ``libffi`` (Debian package: ``libffi-dev``)
* ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``)
* ``msgfmt`` (Debian package ``gettext``)
* ``git``
Your local python environment
@@ -77,7 +80,7 @@ and head to http://localhost:8000/
As we did not implement an overall front page yet, you need to go directly to
http://localhost:8000/control/ for the admin view or, if you imported the test
data as suggested above, to the event page at http://localhost:8000/mrmcd/2015/
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/
.. _`checksandtests`:
@@ -118,8 +121,8 @@ Then execute ``python -m smtpd -n -c DebuggingServer localhost:1025``.
Working with translations
^^^^^^^^^^^^^^^^^^^^^^^^^
If you want to translate new strings that are not yet known to the translation system,
you can use the following command to scan the source code for strings to be translated
If you want to translate new strings that are not yet known to the translation system,
you can use the following command to scan the source code for strings to be translated
and update the ``*.po`` files accordingly::
make localegen

View File

@@ -8,4 +8,5 @@ Contents:
admin/index
development/index
plugins/index

13
doc/plugins/index.rst Normal file
View File

@@ -0,0 +1,13 @@
Plugin documentation
====================
This part of the documentation contains information about available plugins
that can be used to extend pretix's functionality.
If you want to **create** a plugin, please go to the
:ref:`Developer documentation <pluginsetup>` instead.
.. toctree::
:maxdepth: 2
list
pretixdroid

30
doc/plugins/list.rst Normal file
View File

@@ -0,0 +1,30 @@
List of plugins
===============
The following plugins are shipped with pretix and are supported in the same
ways that pretix itself is:
* Bank transfer
* PayPal
* Stripe
* Check-in lists
* pretixdroid
* Report exporter
* Send out emails
* Statistics
* PDF ticket output
The following plugins are not shipped with pretix but are maintained by the
same team:
* `Passbook/Wallet ticket output`_
* `Cartshare`_
The following plugins are from independent third-party authors, so we can make
no statements about their stability:
* `esPass ticket output`_
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
.. _Cartshare: https://github.com/pretix/pretix-cartshare
.. _esPass ticket output: https://github.com/esPass/pretix-espass

174
doc/plugins/pretixdroid.rst Normal file
View File

@@ -0,0 +1,174 @@
pretixdroid HTTP API
====================
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
**Example request**:
.. sourcecode:: http
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/x-www-form-urlencoded
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "ok"
"version": 2
}
**Example error response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "already_redeemed",
"version": 2
}
Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded
* ``already_redeemed`` - Ticket already has been redeemed
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
Searches for a ticket.
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"paid": true
},
...
],
"version": 2
}
:query query: Search query
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
Returns status information, such as the total number of tickets and the
number of performed checkins.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"checkins": 17,
"total": 42,
"version": 2,
"event": {
"name": "Demo Converence",
"slug": "democon",
"date_from": "2016-12-27T17:00:00Z",
"date_to": "2016-12-30T18:00:00Z",
"timezone": "UTC",
"url": "https://demo.pretix.eu/demoorga/democon/",
"organizer": {
"name": "Demo Organizer",
"slug": "demoorga"
},
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkins": 1,
"admission": False,
"total": 1,
"variations": [
{
"name": "Red",
"id": 1,
"checkins": 1,
"total": 12
},
{
"name": "Blue",
"id": 2,
"checkins": 4,
"total": 8
}
]
},
{
"name": "Ticket",
"id": 2,
"checkins": 15,
"admission": True,
"total": 22,
"variations": []
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid

View File

@@ -1,3 +1,4 @@
-r ../src/requirements.txt
sphinx
sphinx-rtd-theme
sphinxcontrib-httpdomain

3
src/.gitignore vendored
View File

@@ -7,5 +7,4 @@ build/
dist/
*.egg-info/
*.bak
static/jsi18n/
pretix/static/jsi18n/

View File

@@ -1,11 +1,13 @@
include LICENSE
include README.rst
recursive-include pretix/static *
recursive-include pretix/static.dist *
recursive-include pretix/locale *
recursive-include pretix/base/templates *
recursive-include pretix/control/templates *
recursive-include pretix/presale/templates *
recursive-include pretix/plugins/banktransfer/templates *
recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static *

View File

@@ -6,7 +6,7 @@ localecompile:
localegen:
./manage.py makemessages --all --ignore "pretix/helpers/*"
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*"
./manage.py makemessages --all -d djangojs --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "build/*"
staticfiles: jsi18n
./manage.py collectstatic --noinput

View File

@@ -1 +1 @@
__version__ = "0.0.0"
__version__ = "1.0.0b2"

View File

@@ -12,9 +12,13 @@ class PretixBaseConfig(AppConfig):
from .services import export, mail, tickets, cart, orders, cleanup # NOQA
try:
from .celery import app as celery_app # NOQA
from .celery_app import app as celery_app # NOQA
except ImportError:
pass
default_app_config = 'pretix.base.PretixBaseConfig'
try:
import pretix.celery_app as celery # NOQA
except ImportError:
pass

View File

@@ -23,11 +23,12 @@ class BaseI18nModelForm(BaseModelForm):
"""
def __init__(self, *args, **kwargs):
event = kwargs.pop('event', None)
locales = kwargs.pop('locales', None)
super().__init__(*args, **kwargs)
if event:
if event or locales:
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = event.settings.get('locales')
field.widget.enabled_langcodes = event.settings.get('locales') if event else locales
class I18nModelForm(six.with_metaclass(ModelFormMetaclass, BaseI18nModelForm)):
@@ -97,12 +98,14 @@ class SettingsForm(forms.Form):
)
def __init__(self, *args, **kwargs):
self.obj = kwargs.pop('obj')
self.obj = kwargs.pop('obj', None)
self.locales = kwargs.pop('locales', None)
kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs)
for k, field in self.fields.items():
if isinstance(field, I18nFormField):
field.widget.enabled_langcodes = self.obj.settings.get('locales')
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):
"""

View File

@@ -39,8 +39,7 @@ class UserSettingsForm(forms.ModelForm):
class Meta:
model = User
fields = [
'givenname',
'familyname',
'fullname',
'locale',
# 'timezone',
'email'

View File

@@ -69,14 +69,16 @@ class LazyI18nString:
if isinstance(self.data, dict):
firstpart = lng.split('-')[0]
similar = [l for l in self.data.keys() if l.startswith(firstpart + "-") or firstpart == l]
if lng in self.data and self.data[lng]:
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 firstpart in self.data:
elif self.data.get(firstpart):
return self.data[firstpart]
elif similar:
return self.data[similar[0]]
elif settings.LANGUAGE_CODE in self.data and self.data[settings.LANGUAGE_CODE]:
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]
@@ -145,6 +147,7 @@ class I18nWidget(forms.MultiWidget):
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):
@@ -158,9 +161,13 @@ class I18nWidget(forms.MultiWidget):
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):

View File

@@ -0,0 +1,15 @@
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)
call_command('compress', verbosity=1, interactive=False)

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.3 on 2016-11-29 13:30
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0047_auto_20161126_1300'),
]
operations = [
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'),
),
]

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-08 16:47
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0048_auto_20161129_1330'),
('pretixdroid', '0002_auto_20161208_1644'),
]
state_operations = [
migrations.CreateModel(
name='Checkin',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True)),
('position', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pretixdroid_checkins', to='pretixbase.OrderPosition')),
],
),
]
operations = [
migrations.SeparateDatabaseAndState(state_operations=state_operations)
]

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-21 17:05
from __future__ import unicode_literals
from django.db import migrations, models
def forwards(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()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0049_checkin'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='positionid',
field=models.PositiveIntegerField(default=1),
),
migrations.RunPython(
forwards, migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-21 17:20
from __future__ import unicode_literals
from django.db import migrations, models
import pretix.base.models.orders
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()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0050_orderposition_positionid'),
]
operations = [
migrations.RunPython(
invalidate_ticket_cache, migrations.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,
),
]

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2016-12-31 15:33
from __future__ import unicode_literals
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0051_auto_20161221_1720'),
]
operations = [
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'),
),
]

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-04 12:52
from __future__ import unicode_literals
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.validators
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0052_auto_20161231_1533'),
]
operations = [
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),
),
]

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 10:58
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.event
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0053_auto_20170104_1252'),
]
operations = [
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),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 12:37
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0054_auto_20170107_1058'),
]
operations = [
migrations.AddField(
model_name='organizerpermission',
name='can_change_permissions',
field=models.BooleanField(default=True, verbose_name='Can change permissions'),
),
]

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 12:51
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.organizer
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0055_organizerpermission_can_change_permissions'),
]
operations = [
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),
),
]

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 15:31
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import pretix.base.validators
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):
dependencies = [
('pretixbase', '0056_auto_20170107_1251'),
]
operations = [
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(merge_names, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-07 15:33
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0057_auto_20170107_1531'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='familyname',
),
migrations.RemoveField(
model_name='user',
name='givenname',
),
]

View File

@@ -1,6 +1,10 @@
from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .event import Event, EventLock, EventPermission, EventSetting
from .checkin import Checkin
from .event import (
Event, EventLock, EventPermission, EventSetting, RequiredAction,
generate_invite_token,
)
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,

View File

@@ -43,10 +43,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:param email: The user's email address, used for identification.
:type email: str
:param givenname: The user's given name. May be empty or null.
:type givenname: str
:param familyname: The user's given name. May be empty or null.
:type familyname: str
:param fullname: The user's full name. May be empty or null.
:type fullname: str
:param is_active: Whether this user account is activated.
:type is_active: bool
:param is_staff: ``True`` for system operators.
@@ -64,10 +62,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('E-mail'))
givenname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Given name'))
familyname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Family name'))
fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name'))
is_active = models.BooleanField(default=True,
verbose_name=_('Is active'))
is_staff = models.BooleanField(default=False,
@@ -100,14 +96,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
"""
Returns the first of the following user properties that is found to exist:
* Given name
* Family name
* Full name
* Email address
Only present for backwards compatibility
"""
if self.givenname:
return self.givenname
elif self.familyname:
return self.familyname
if self.fullname:
return self.fullname
else:
return self.email
@@ -115,20 +110,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
"""
Returns the first of the following user properties that is found to exist:
* A combination of given name and family name, depending on the locale
* Given name
* Family name
* User name
* Full name
* Email address
"""
if self.givenname and not self.familyname:
return self.givenname
elif not self.givenname and self.familyname:
return self.familyname
elif self.familyname and self.givenname:
return _('%(family)s, %(given)s') % {
'family': self.familyname,
'given': self.givenname
}
if self.fullname:
return self.fullname
else:
return self.email

View File

@@ -71,4 +71,4 @@ class LoggedModel(models.Model, LoggingMixin):
:return: A QuerySet of LogEntry objects
"""
return self.logentries.all().select_related('user')
return self.logentries.all().select_related('user', 'event')

View File

@@ -0,0 +1,10 @@
from django.db import models
from django.utils.timezone import now
class Checkin(models.Model):
"""
A checkin object is created when a person enters the event.
"""
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
datetime = models.DateTimeField(default=now)

View File

@@ -1,14 +1,18 @@
import string
import uuid
from datetime import date, datetime, time
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import RegexValidator
from django.db import models
from django.template.defaultfilters import date as _date
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from pretix.base.email import CustomSMTPBackend
@@ -16,6 +20,7 @@ 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
from pretix.helpers.daterange import daterange
from .auth import User
from .organizer import Organizer
@@ -61,7 +66,7 @@ class Event(LoggedModel):
max_length=50, db_index=True,
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."),
"This will be used in order codes, invoice numbers, links and bank transfer references."),
validators=[
RegexValidator(
regex="^[a-zA-Z0-9.-]+$",
@@ -69,7 +74,7 @@ class Event(LoggedModel):
),
EventSlugBlacklistValidator()
],
verbose_name=_("Slug"),
verbose_name=_("Short form"),
)
live = models.BooleanField(default=False, verbose_name=_("Shop is live"))
permitted = models.ManyToManyField(User, through='EventPermission',
@@ -152,6 +157,12 @@ class Event(LoggedModel):
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
)
def get_date_range_display(self, tz=None) -> str:
tz = tz or pytz.timezone(self.settings.timezone)
if not self.settings.show_date_to or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -208,6 +219,85 @@ class Event(LoggedModel):
else:
return get_connection(fail_silently=False)
@property
def payment_term_last(self):
tz = pytz.timezone(self.settings.timezone)
return make_aware(datetime.combine(
self.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
def copy_data_from(self, other):
from . import ItemCategory, Item, Question, Quota
self.plugins = other.plugins
self.save()
category_map = {}
for c in ItemCategory.objects.filter(event=other):
category_map[c.pk] = c
c.pk = None
c.event = self
c.save()
item_map = {}
variation_map = {}
for i in Item.objects.filter(event=other).prefetch_related('variations'):
vars = list(i.variations.all())
item_map[i.pk] = i
i.pk = None
i.event = self
if i.picture:
i.picture.save(i.picture.name, i.picture)
if i.category_id:
i.category = category_map[i.category_id]
i.save()
for v in vars:
variation_map[v.pk] = v
v.pk = None
v.item = i
v.save()
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
items = list(q.items.all())
vars = list(q.variations.all())
q.pk = None
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
for v in vars:
q.variations.add(variation_map[v.pk])
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all())
opts = list(q.options.all())
q.pk = None
q.event = self
q.save()
for i in items:
q.items.add(item_map[i.pk])
for o in opts:
o.pk = None
o.question = q
o.save()
for s in EventSetting.objects.filter(object=other):
s.object = self
s.pk = None
if s.value.startswith('file://'):
fi = default_storage.open(s.value[7:], 'rb')
nonce = get_random_string(length=8)
fname = '%s/%s/%s.%s.%s' % (
self.organizer.slug, self.slug, s.key, nonce, s.value.split('.')[-1]
)
newname = default_storage.save(fname, fi)
s.value = 'file://' + newname
s.save()
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class EventPermission(models.Model):
"""
@@ -229,7 +319,9 @@ class EventPermission(models.Model):
"""
event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_change_settings = models.BooleanField(
default=True,
verbose_name=_("Can change event settings")
@@ -274,3 +366,42 @@ class EventLock(models.Model):
event = models.CharField(max_length=36, primary_key=True)
date = models.DateTimeField(auto_now=True)
token = models.UUIDField(default=uuid.uuid4)
class RequiredAction(models.Model):
"""
Represents an action that is to be done by an admin. The admin will be
displayed a list of actions to do.
:param datatime: The timestamp of the required action
:type datetime: datetime
:param user: The user that performed the action
:type user: User
:param done: If this action has been completed or dismissed
:type done: bool
:param action_type: The type of action that has to be performed. This is
used to look up the renderer used to describe the action in a human-
readable way. This should be some namespaced value using dotted
notation to avoid duplicates, e.g.
``"pretix.plugins.banktransfer.incoming_transfer"``.
:type action_type: str
:param data: Arbitrary data that can be used by the log action renderer
:type data: str
"""
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
done = models.BooleanField(default=False)
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.CASCADE)
action_type = models.CharField(max_length=255)
data = models.TextField(default='{}')
class Meta:
ordering = ('datetime',)
def display(self, request):
from ..signals import requiredaction_display
for receiver, response in requiredaction_display.send(self.event, action=self, request=request):
if response:
return response
return self.action_type

View File

@@ -1,6 +1,11 @@
import json
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
class LogEntry(models.Model):
@@ -32,7 +37,7 @@ class LogEntry(models.Model):
data = models.TextField(default='{}')
class Meta:
ordering = ('-datetime', )
ordering = ('-datetime',)
def display(self):
from ..signals import logentry_display
@@ -41,3 +46,87 @@ class LogEntry(models.Model):
if response:
return response
return self.action_type
@cached_property
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event
if self.content_type.model_class() is Event:
return ''
co = self.content_object
a_map = None
a_text = None
if isinstance(co, Order):
a_text = _('Order {val}')
a_map = {
'href': reverse('control:event.order', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'code': co.code
}),
'val': co.code,
}
elif isinstance(co, Voucher):
a_text = _('Voucher {val}')
a_map = {
'href': reverse('control:event.voucher', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'voucher': co.id
}),
'val': co.code[:6],
}
elif isinstance(co, Item):
a_text = _('Product {val}')
a_map = {
'href': reverse('control:event.item', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'item': co.id
}),
'val': co.name,
}
elif isinstance(co, Quota):
a_text = _('Quota {val}')
a_map = {
'href': reverse('control:event.items.quotas.show', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'quota': co.id
}),
'val': co.name,
}
elif isinstance(co, ItemCategory):
a_text = _('Category {val}')
a_map = {
'href': reverse('control:event.items.categories.edit', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'category': co.id
}),
'val': co.name,
}
elif isinstance(co, Question):
a_text = _('Question {val}')
a_map = {
'href': reverse('control:event.items.questions.show', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'question': co.id
}),
'val': co.question,
}
if a_text and a_map:
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
return a_text.format_map(a_map)
elif a_text:
return a_text
else:
return ''
@cached_property
def parsed_data(self):
return json.loads(self.data)

View File

@@ -1,19 +1,20 @@
import copy
import string
from datetime import date, datetime, time
from datetime import datetime
from decimal import Decimal
from typing import List, Union
import pytz
from django.conf import settings
from django.db import models
from django.db.models import F
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils.crypto import get_random_string
from django.utils.timezone import make_aware, now
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from ..decimal import round_decimal
from .base import CachedFile, LoggedModel
from .base import LoggedModel
from .event import Event
from .items import Item, ItemVariation, Question, QuestionOption, Quota
@@ -271,13 +272,7 @@ class Order(LoggedModel):
}
if self.event.settings.get('payment_term_last'):
tz = pytz.timezone(self.event.settings.timezone)
last_date = make_aware(datetime.combine(
self.event.settings.get('payment_term_last', as_type=date),
time(hour=23, minute=59, second=59)
), tz)
if now() > last_date:
if now() > self.event.payment_term_last:
return error_messages['late']
if self.status == self.STATUS_PENDING:
return True
@@ -438,6 +433,7 @@ class OrderPosition(AbstractPosition):
:param order: The order this position is a part of
:type order: Order
"""
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
@@ -463,11 +459,12 @@ class OrderPosition(AbstractPosition):
from . import Voucher
ops = []
for cartpos in cp:
for i, cartpos in enumerate(cp):
op = OrderPosition(order=order)
for f in AbstractPosition._meta.fields:
setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.positionid = i + 1
op.save()
for answ in cartpos.answers.all():
answ.orderposition = op
@@ -475,6 +472,10 @@ class OrderPosition(AbstractPosition):
answ.save()
if cartpos.voucher:
Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1)
cartpos.voucher.log_action('pretix.voucher.redeemed', {
'order_code': order.code
})
cartpos.delete()
return ops
@@ -493,6 +494,9 @@ class OrderPosition(AbstractPosition):
def save(self, *args, **kwargs):
if self.tax_rate is None:
self._calculate_tax()
if self.pk is None:
while OrderPosition.objects.filter(secret=self.secret).exists():
self.secret = generate_position_secret()
return super().save(*args, **kwargs)
@@ -559,7 +563,28 @@ class InvoiceAddress(models.Model):
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'))
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(
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
)
class CachedTicket(models.Model):
order_position = models.ForeignKey(OrderPosition, on_delete=models.CASCADE)
cachedfile = models.ForeignKey(CachedFile, on_delete=models.CASCADE, null=True)
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=cachedticket_name)
@receiver(post_delete, sender=CachedTicket)
def cachedticket_delete(sender, instance, **kwargs):
if instance.file:
# Pass false so FileField doesn't save the model.
instance.file.delete(False)

View File

@@ -1,5 +1,8 @@
import string
from django.core.validators import RegexValidator
from django.db import models
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
@@ -38,7 +41,7 @@ class Organizer(LoggedModel):
),
OrganizerSlugBlacklistValidator()
],
verbose_name=_("Slug"),
verbose_name=_("Short form"),
)
permitted = models.ManyToManyField(User, through='OrganizerPermission',
related_name="organizers")
@@ -76,6 +79,10 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
class OrganizerPermission(models.Model):
"""
The relation between an Organizer and a User who has permissions to
@@ -91,11 +98,17 @@ class OrganizerPermission(models.Model):
"""
organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="organizer_perms")
user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True)
invite_email = models.EmailField(null=True, blank=True)
invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True)
can_create_events = models.BooleanField(
default=True,
verbose_name=_("Can create events"),
)
can_change_permissions = models.BooleanField(
default=True,
verbose_name=_("Can change permissions"),
)
class Meta:
verbose_name = _("Organizer permission")

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
@@ -5,6 +7,7 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event
from .items import Item, ItemVariation, Quota
@@ -40,8 +43,11 @@ class Voucher(LoggedModel):
:type block_quota: bool
:param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out
:type allow_ignore_quota: bool
:param price: If set, the voucher will allow the sale of associated items for this price
:type price: decimal.Decimal
:param price_mode: Sets how this voucher affects a product's price. Can be ``none``, ``set``, ``subtract``
or ``percent``.
:type price_mode: str
:param value: The value by which the price should be modified in the way specified by ``price_mode``.
:type value: decimal.Decimal
:param item: If set, the item to sell
:type item: Item
:param variation: If set, the variation to sell
@@ -59,6 +65,13 @@ class Voucher(LoggedModel):
* You need to either select a quota or an item
* If you select an item that has variations but do not select a variation, you cannot set block_quota
"""
PRICE_MODES = (
('none', _('No effect')),
('set', _('Set product price to')),
('subtract', _('Subtract from product price')),
('percent', _('Reduce product price by (%)')),
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
@@ -98,10 +111,15 @@ class Voucher(LoggedModel):
"If activated, a holder of this voucher code can buy tickets, even if there are none left."
)
)
price = models.DecimalField(
verbose_name=_("Set product price to"),
price_mode = models.CharField(
verbose_name=_("Price mode"),
max_length=100,
choices=PRICE_MODES,
default='none'
)
value = models.DecimalField(
verbose_name=_("Voucher value"),
decimal_places=2, max_digits=10, null=True, blank=True,
help_text=_('If empty, the product will cost its normal price.')
)
item = models.ForeignKey(
Item, related_name='vouchers',
@@ -208,3 +226,19 @@ class Voucher(LoggedModel):
if self.valid_until and self.valid_until < now():
return False
return True
def calculate_price(self, original_price: Decimal) -> Decimal:
"""
Returns how the price given in original_price would be modified if this
voucher is applied, i.e. replaced by a different price or reduced by a
certain percentage. If the voucher does not modify the price, the
original price will be returned.
"""
if self.value is not None:
if self.price_mode == 'set':
return self.value
elif self.price_mode == 'subtract':
return original_price - self.value
elif self.price_mode == 'percent':
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
return original_price

View File

@@ -365,8 +365,12 @@ class BasePaymentProvider:
whether the user should be presented with an option to retry the payment. The default
implementation always returns False.
If you want to enable retrials for your payment method, the best is to just return
``self._is_still_available()`` from this method to disable it as soon as the method
gets disabled or the methods end date is reached.
The retry workflow is also used if a user switches to this payment method for an existing
order! Therefore, they can only switch to your p
order!
:param order: The order object
"""

View File

@@ -15,11 +15,10 @@ import time
from django.conf import settings
from django.db import transaction
from pretix.celery import app
from pretix.celery_app import app
class ProfiledTask(app.Task):
abstract = True
def __call__(self, *args, **kwargs):
@@ -43,7 +42,6 @@ class TransactionAwareTask(ProfiledTask):
Task class which is aware of django db transactions and only executes tasks
after transaction has been committed
"""
abstract = True
def apply_async(self, *args, **kwargs):
"""

View File

@@ -12,7 +12,7 @@ from pretix.base.models import (
)
from pretix.base.services.async import ProfiledTask
from pretix.base.services.locking import LockTimeoutException
from pretix.celery import app
from pretix.celery_app import app
class CartError(LazyLocaleException):
@@ -168,11 +168,10 @@ def _add_new_items(event: Event, items: List[dict],
err = err or error_messages['in_part']
quota_ok = min(quota_ok, avail[1])
if voucher and voucher.price is not None:
price = voucher.price
else:
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.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']
@@ -219,7 +218,7 @@ def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> No
raise CartError(err)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
"""
Adds a list of items to a user's cart.
@@ -260,7 +259,7 @@ def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> No
cp.delete()
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
@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:
"""
Removes a list of items from a user's cart.

View File

@@ -6,7 +6,7 @@ from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.async import ProfiledTask
from pretix.base.signals import register_data_exporters
from pretix.celery import app
from pretix.celery_app import app
@app.task(base=ProfiledTask)

View File

@@ -25,7 +25,7 @@ from pretix.base.i18n import LazyI18nString, 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
from pretix.celery import app
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction

View File

@@ -1,18 +1,23 @@
import logging
from typing import Any, Dict
import bleach
import cssutils
import markdown
from django.conf import settings
from django.core.mail import EmailMessage, get_connection
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template
from django.utils.translation import ugettext as _
from inlinestyler.utils import inline_css
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Event, Order
from pretix.celery import app
from pretix.celery_app import app
from pretix.multidomain.urlreverse import build_absolute_uri
logger = logging.getLogger('pretix.base.mail')
INVALID_ADDRESS = 'invalid-pretix-mail-address'
cssutils.log.setLevel(logging.CRITICAL)
class TolerantDict(dict):
@@ -63,37 +68,63 @@ def mail(email: str, subject: str, template: str,
body = str(template)
if context:
body = body.format_map(TolerantDict(context))
body_md = bleach.linkify(bleach.clean(markdown.markdown(body), tags=bleach.ALLOWED_TAGS + [
'p',
]))
else:
tpl = get_template(template)
body = tpl.render(context)
body_md = bleach.linkify(markdown.markdown(body))
sender = event.settings.get('mail_from') if event else settings.MAIL_FROM
subject = str(subject)
body_plain = body
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'body': body_md,
'color': '#8E44B3'
}
if event:
htmlctx['event'] = event
htmlctx['color'] = event.settings.primary_color
prefix = event.settings.get('mail_prefix')
if prefix:
subject = "[%s] %s" % (prefix, subject)
body += "\r\n\r\n-- \r\n"
body += _(
body_plain += "\r\n\r\n-- \r\n"
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
if order:
body += "\r\n"
body += _(
"You can view your order details at the following URL:\r\n{orderurl}."
).format(event=event.name, orderurl=build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}))
body += "\r\n"
return mail_send([email], subject, body, sender, event.id if event else None, headers)
htmlctx['order'] = order
body_plain += "\r\n"
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}
)
)
body_plain += "\r\n"
tpl = get_template('pretixbase/email/plainwrapper.html')
body_html = tpl.render(htmlctx)
return mail_send([email], subject, body_plain, body_html, sender, event.id if event else None, headers)
@app.task
def mail_send_task(to: str, subject: str, body: str, sender: str, event: int=None, headers: dict=None) -> bool:
email = EmailMessage(subject, body, sender, to=to, headers=headers)
def mail_send_task(to: str, subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
email.attach_alternative(inline_css(html), "text/html")
if event:
event = Event.objects.get(id=event)
backend = event.get_mail_backend()

View File

@@ -21,7 +21,7 @@ from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User, Voucher,
)
from pretix.base.models.orders import InvoiceAddress
from pretix.base.models.orders import CachedTicket, InvoiceAddress
from pretix.base.payment import BasePaymentProvider
from pretix.base.services.async import ProfiledTask
from pretix.base.services.invoices import (
@@ -32,7 +32,7 @@ from pretix.base.services.mail import SendMailException, mail
from pretix.base.signals import (
order_paid, order_placed, periodic_task, register_payment_providers,
)
from pretix.celery import app
from pretix.celery_app import app
from pretix.multidomain.urlreverse import build_absolute_uri
error_messages = {
@@ -43,6 +43,7 @@ error_messages = {
'price_changed': _('The price of some of the items in your cart has changed in the '
'meantime. Please see below for details.'),
'internal': _("An internal error occured, please try again."),
'empty': _("Your cart is empty."),
'busy': _('We were not able to process your request completely as the '
'server was too busy. Please try again.'),
'not_started': _('The presale period for this event has not yet started.'),
@@ -233,8 +234,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['voucher_expired']
cp.delete()
continue
if cp.voucher.price is not None:
price = cp.voucher.price
price = cp.voucher.calculate_price(price)
if price != cp.price and not (cp.item.free_price and cp.price > price):
positions[i] = cp
@@ -282,7 +282,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
tz = pytz.timezone(event.settings.timezone)
exp_by_date = now_dt.astimezone(tz) + timedelta(days=event.settings.get('payment_term_days', as_type=int))
exp_by_date = exp_by_date.replace(hour=23, minute=59, second=59, microsecond=0)
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
@@ -346,6 +346,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
with event.lock() as now_dt:
positions = list(CartPosition.objects.filter(
id__in=position_ids).select_related('item', 'variation'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions)
@@ -498,6 +500,7 @@ class OrderChangeManager:
if isinstance(op, self.ItemOperation):
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'new_item': op.item.pk,
@@ -513,6 +516,7 @@ class OrderChangeManager:
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_price': op.position.price,
'new_price': op.price
})
@@ -522,6 +526,7 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation):
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
'old_variation': op.position.variation.pk if op.position.variation else None,
'old_price': op.position.price,
@@ -579,9 +584,13 @@ class OrderChangeManager:
self._perform_operations()
self._recalculate_total_and_payment_fee()
self._reissue_invoice()
self._clear_tickets_cache()
self._check_paid_to_free()
self._notify_user()
def _clear_tickets_cache(self):
CachedTicket.objects.filter(order_position__order=self.order).delete()
def _get_payment_provider(self):
responses = register_payment_providers.send(self.order.event)
pprov = None
@@ -593,7 +602,7 @@ class OrderChangeManager:
raise OrderError(error_messages['internal'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
try:
@@ -605,7 +614,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
return OrderError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1)
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_order(self, order: int, user: int=None):
try:
try:

View File

@@ -1,39 +1,40 @@
from datetime import timedelta
import os
from django.core.files.base import ContentFile
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CachedTicket, Event, Order, OrderPosition, cachedfile_name,
)
from pretix.base.models import CachedTicket, Event, Order, OrderPosition
from pretix.base.services.async import ProfiledTask
from pretix.base.signals import register_ticket_outputs
from pretix.celery import app
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
@app.task(base=ProfiledTask)
def generate(order_position: str, provider: str):
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
ct = CachedTicket.objects.get_or_create(order_position=order_position, provider=provider)[0]
if not ct.cachedfile:
cf = CachedFile()
cf.date = now()
cf.expires = order_position.order.event.date_from + timedelta(days=30)
cf.save()
ct.cachedfile = cf
ct.save()
try:
ct = CachedTicket.objects.get(order_position=order_position, provider=provider)
except CachedTicket.MultipleObjectsReturned:
CachedTicket.objects.filter(order_position=order_position, provider=provider).delete()
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
type='', file=None)
except CachedTicket.DoesNotExist:
ct = CachedTicket.objects.create(order_position=order_position, provider=provider, extension='',
type='', file=None)
with language(order_position.order.locale):
responses = register_ticket_outputs.send(order_position.order.event)
for receiver, response in responses:
prov = response(order_position.order.event)
if prov.identifier == provider:
ct.cachedfile.filename, ct.cachedfile.type, data = prov.generate(order_position)
ct.cachedfile.file.save(cachedfile_name(ct.cachedfile, ct.cachedfile.filename), ContentFile(data))
ct.cachedfile.save()
filename, ct.type, data = prov.generate(order_position)
path, ext = os.path.splitext(filename)
ct.extension = ext
ct.save()
ct.file.save(filename, ContentFile(data))
class DummyRollbackException(Exception):

View File

@@ -52,6 +52,18 @@ class EventPluginSignal(django.dispatch.Signal):
return responses
event_live_issues = EventPluginSignal(
providing_args=[]
)
"""
This signal is sent out to determine whether an event can be taken live. If you want to
prevent the event from going live, return a string that will be displayed to the user
as the error message. If you don't, your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_payment_providers = EventPluginSignal(
providing_args=[]
)
@@ -110,7 +122,21 @@ To display an instance of the ``LogEntry`` model to a human user,
``pretix.base.signals.logentry_display`` will be sent out with a ``logentry`` argument.
The first received response that is not ``None`` will be used to display the log entry
to the user.
to the user. The receivers are expected to return plain text.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
requiredaction_display = EventPluginSignal(
providing_args=["action", "request"]
)
"""
To display an instance of the ``RequiredAction`` model to a human user,
``pretix.base.signals.requiredaction_display`` will be sent out with a ``action`` argument.
You will also get the current ``request`` in a different argument.
The first received response that is not ``None`` will be used to display the log entry
to the user. The receivers are expected to return HTML code.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""

View File

@@ -0,0 +1,166 @@
{% load eventurl %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=false">
</head>
<style type="text/css">
body {
background-color: #e8e8e8;
background-position: top;
background-repeat: repeat-x;
font-family: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 20px;
color: #333;
margin: 0;
}
.header h1 {
margin-top: 20px;
margin-bottom: 5px;
}
.header h1 a {
text-decoration: none;
}
a {
color: {{ color }};
font-weight: bold;
}
a:hover, a:focus {
color: {{ color }};
text-decoration: underline;
}
a:hover, a:active {
outline: 0;
}
p {
margin: 0 0 10px;
/* These are technically the same, but use both */
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
/* This is the dangerous one in WebKit, as it breaks things wherever */
word-break: break-all;
/* Instead use this non-standard one: */
word-break: break-word;
/* Adds a hyphen where the word breaks, if supported (No Blink) */
-ms-hyphens: auto;
-moz-hyphens: auto;
-webkit-hyphens: auto;
hyphens: auto;
}
.footer {
padding: 10px;
text-align: center;
font-size: 12px;
}
.content {
padding: 8px 18px 8px;
}
::selection {
background: {{ color }};
color: #FFF;
}
table {
width: 90%;
max-width: 900px;
border-spacing: 0px;
border-collapse: separate;
margin: auto;
}
@media (max-width: 480px) {
.header h1 {
font-size: 19px;
line-height: 24px;
margin: 0 9px 3px 0;
border-radius: 5px 5px;
-webkit-border-radius: 5px 5px;
-moz-border-radius: 5px 5px;
}
.header h1 a {
padding: 3px 9px;
display: block;
}
.header {
margin: 0;
padding: 12px 0 8px;
}
}
td.containertd {
background-color: #FFFFFF;
border: 1px solid #cccccc;
}
{% block addcss %}{% endblock %}
</style>
<body>
<table>
<tr>
<td class="header" background="">
{% if event %}
<h1><a href="{% eventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
{% else %}
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
{% endif %}
</td>
</tr>
<tr>
<td class="containertd">
<div class="content">
{{ body|safe }}
</div>
</td>
</tr>
{% if order %}
<tr>
<td class="gap"></td>
</tr>
<tr>
<td class="order containertd">
<div class="content">
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% eventurl event "presale:event.order" order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
</div>
</td>
</tr>
{% endif %}
<tr>
<td class="footer">
<div>
{% with 'target="blank" href="https://pretix.eu"'|safe as a_attr %}
{% blocktrans trimmed %}
powered by <a {{ a_attr }}>pretix</a>
{% endblocktrans %}
{% endwith %}
</div>
</td>
</tr>
</table>
<br/>
<br/>
</body>
</html>

View File

@@ -32,7 +32,8 @@ class BaseTicketOutput:
def generate(self, order: 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.
filename, a file type and file content. The extension will be taken from the filename
which is otherwise ignored.
"""
raise NotImplementedError()

View File

@@ -8,7 +8,7 @@ from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils.translation import ugettext as _
from pretix.celery import app
from pretix.celery_app import app
logger = logging.getLogger('pretix.base.async')
@@ -17,6 +17,7 @@ class AsyncAction:
task = None
success_url = None
error_url = None
known_errortypes = []
def do(self, *args):
if not isinstance(self.task, app.Task):
@@ -53,7 +54,7 @@ class AsyncAction:
def _return_ajax_result(self, res, timeout=.5):
if not res.ready():
try:
res.get(timeout=timeout)
res.get(timeout=timeout, propagate=False)
except celery.exceptions.TimeoutError:
pass
@@ -118,8 +119,13 @@ class AsyncAction:
return redirect(self.get_error_url())
def get_error_message(self, exception):
logger.error('Unexpected exception: %r' % exception)
return _('An unexpected error has occured.')
if isinstance(exception, dict) and exception['exc_type'] in self.known_errortypes:
return exception['exc_message']
elif exception.__class__.__name__ in self.known_errortypes:
return str(exception)
else:
logger.error('Unexpected exception: %r' % exception)
return _('An unexpected error has occured.')
def get_success_message(self, value):
return _('The task has been completed.')

View File

@@ -1,6 +1,4 @@
import os
from django.http import FileResponse, HttpRequest, HttpResponse
from django.http import FileResponse, Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.views.generic import TemplateView
@@ -13,15 +11,17 @@ class DownloadView(TemplateView):
@cached_property
def object(self) -> CachedFile:
return get_object_or_404(CachedFile, id=self.kwargs['id'])
try:
return get_object_or_404(CachedFile, id=self.kwargs['id'])
except ValueError: # Invalid URLs
raise Http404()
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if 'ajax' in request.GET:
return HttpResponse('1' if self.object.file else '0')
elif self.object.file:
resp = FileResponse(self.object.file.file, content_type=self.object.type)
_, ext = os.path.splitext(self.object.filename)
resp['Content-Disposition'] = 'attachment; filename="{}{}"'.format(self.object.id, ext)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(self.object.filename)
return resp
else:
return super().get(request, *args, **kwargs)

View File

@@ -1,3 +1,5 @@
import hmac
from django.conf import settings
from django.http import HttpResponse
@@ -26,9 +28,9 @@ def serve_metrics(request):
user, passphrase = credentials.strip().decode("base64").split(":", 1)
if user != settings.METRICS_USER:
if not hmac.compare_digest(user, settings.METRICS_USER):
return unauthed_response()
if passphrase != settings.METRICS_PASSPHRASE:
if not hmac.compare_digest(passphrase, settings.METRICS_PASSPHRASE):
return unauthed_response()
# ok, the request passed the authentication-barrier, let's hand out the metrics:

View File

@@ -1,29 +0,0 @@
import os
from celery import Celery
from celery.utils.mail import ErrorMail
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
from django.conf import settings
app = Celery('pretix')
class MyErrorMail(ErrorMail):
def should_send(self, context, exc):
from pretix.base.services.orders import OrderError
from pretix.base.services.cart import CartError
blacklist = (OrderError, CartError)
return not isinstance(exc, blacklist)
app.config_from_object('django.conf:settings')
app.conf.CELERY_ANNOTATIONS = {
'*': {
'ErrorMail': MyErrorMail,
}
}
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

18
src/pretix/celery_app.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
from django.conf import settings
app = Celery('pretix')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
if hasattr(settings, 'RAVEN_CONFIG'):
# Celery signal registration
from raven.contrib.celery import register_signal
from raven.contrib.django.models import client
register_signal(client, ignore_expected=True)

View File

@@ -2,19 +2,50 @@ from django import forms
from django.conf import settings
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 pytz import common_timezones
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.i18n import I18nFormField, I18nTextarea
from pretix.base.models import Event
from pretix.base.models import Event, Organizer
from pretix.control.forms import ExtFileField
class EventCreateForm(I18nModelForm):
class EventWizardFoundationForm(forms.Form):
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Use languages"),
widget=forms.CheckboxSelectMultiple,
help_text=_('Choose all languages that your event should be available in.')
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
queryset=Organizer.objects.filter(
id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True)
),
widget=forms.RadioSelect,
empty_label=None,
required=True
)
class EventWizardBasicsForm(I18nModelForm):
error_messages = {
'duplicate_slug': _("You already used this slug for a different event. Please choose a new one."),
}
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
locale = forms.ChoiceField(
choices=settings.LANGUAGES,
label=_("Default language"),
)
class Meta:
model = Event
@@ -36,7 +67,19 @@ class EventCreateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
self.locales = kwargs.get('locales')
kwargs.pop('user')
super().__init__(*args, **kwargs)
self.initial['timezone'] = get_current_timezone_name()
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
def clean(self):
data = super().clean()
if data['locale'] not in self.locales:
raise ValidationError({
'locale': _('Your default locale must also be enabled for your event (see box above).')
})
return data
def clean_slug(self):
slug = self.cleaned_data['slug']
@@ -48,27 +91,24 @@ class EventCreateForm(I18nModelForm):
return slug
class EventCreateSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
locales = forms.MultipleChoiceField(
choices=settings.LANGUAGES,
label=_("Available langauges"),
)
locale = forms.ChoiceField(
choices=settings.LANGUAGES,
label=_("Default language"),
)
class EventWizardCopyForm(forms.Form):
def clean(self):
data = super().clean()
if data['locale'] not in data['locales']:
raise ValidationError({
'locale': _('Your default locale must also be enabled for your event (see box above).')
})
return data
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
kwargs.pop('locales')
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['copy_from_event'] = forms.ModelChoiceField(
label=_("Copy configuration from"),
queryset=Event.objects.filter(
id__in=self.user.event_perms.filter(
can_change_items=True, can_change_settings=True
).values_list('event', flat=True)
),
widget=forms.RadioSelect,
empty_label=_('Do not copy'),
required=True
)
class EventUpdateForm(I18nModelForm):

View File

@@ -1,10 +1,12 @@
import copy
from django import forms
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 pretix.base.forms import I18nModelForm
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,
@@ -153,6 +155,28 @@ class ItemUpdateForm(I18nModelForm):
}
class ItemVariationsFormSet(I18nFormSet):
def clean(self):
super().clean()
for f in self.forms:
if hasattr(f, '_delete_fail'):
f.fields['DELETE'].initial = False
f.fields['DELETE'].disabled = True
raise ValidationError(
message=_('The variation "%s" cannot be deleted because it has already been ordered by a user or '
'currently is in a users\'s cart. Please set the variation as "inactive" instead.'),
params=(str(f.instance),)
)
def _should_delete_form(self, form):
should_delete = super()._should_delete_form(form)
if should_delete and (form.instance.orderposition_set.exists() or form.instance.cartposition_set.exists()):
form._delete_fail = True
return False
return form.cleaned_data.get(DELETION_FIELD_NAME, False)
class ItemVariationForm(I18nModelForm):
class Meta:
model = ItemVariation

View File

@@ -21,9 +21,9 @@ class ExtendForm(I18nModelForm):
def clean(self):
data = super().clean()
data['expires'] = data['expires'].replace(hour=23, minute=59, second=59)
if data['expires'] < now():
raise ValidationError(_('The new expiry date needs to be in the future.'))
data['expires'] = data['expires'].replace(hour=23, minute=59, second=59)
return data
@@ -105,6 +105,12 @@ class OrderPositionChangeForm(forms.Form):
class OrderContactForm(forms.ModelForm):
regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'),
help_text=_('Regenerates the order and ticket secrets. You will '
'need to re-send the link to the order page to the user and '
'the user will need to download his tickets again. The old '
'versions will be invalid.'))
class Meta:
model = Order
fields = ['email']

View File

@@ -22,8 +22,8 @@ class VoucherForm(I18nModelForm):
model = Voucher
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag',
'comment', 'max_usages'
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -187,8 +187,8 @@ class VoucherBulkForm(VoucherForm):
model = Voucher
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment',
'max_usages'
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),

View File

@@ -5,9 +5,14 @@ from django.dispatch import receiver
from django.utils import formats
from django.utils.translation import ugettext_lazy as _
from pretix.base.i18n import LazyI18nString
from pretix.base.models import Event, ItemVariation, LogEntry
from pretix.base.signals import logentry_display
OVERVIEW_BLACKLIST = [
'pretix.plugins.sendmail.order.email.sent'
]
def _display_order_changed(event: Event, logentry: LogEntry):
data = json.loads(logentry.data)
@@ -20,14 +25,18 @@ def _display_order_changed(event: Event, logentry: LogEntry):
new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']:
new_item += ' - ' + str(event.itemvariations.get(pk=data['new_variation']))
return text + ' ' + _('{old_item} ({old_price} {currency}) changed to {new_item} ({new_price} {currency}).').format(
return text + ' ' + _('Position #{posid}: {old_item} ({old_price} {currency}) changed '
'to {new_item} ({new_price} {currency}).').format(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item,
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
)
elif logentry.action_type == 'pretix.event.order.changed.price':
return text + ' ' + _('Price of a position changed from {old_price} {currency} to {new_price} {currency}.').format(
return text + ' ' + _('Price of position #{posid} changed from {old_price} {currency} '
'to {new_price} {currency}.').format(
posid=data.get('positionid', '?'),
old_price=formats.localize(Decimal(data['old_price'])),
new_price=formats.localize(Decimal(data['new_price'])),
currency=event.currency
@@ -36,7 +45,8 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('{old_item} ({old_price} {currency}) removed.').format(
return text + ' ' + _('Position #{posid} ({old_item}, {old_price} {currency}) removed.').format(
posid=data.get('positionid', '?'),
old_item=old_item,
old_price=formats.localize(Decimal(data['old_price'])),
currency=event.currency
@@ -49,44 +59,92 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.modified': _('The order details have been modified.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.resend': _('The link to the order detail page has been resent to the user.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'),
'pretix.event.order.expired': _('The order has been marked as expired.'),
'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.placed': _('The order has been created.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'),
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.contact.changed': _('The email address has been changed.'),
'pretix.event.order.payment.changed': _('The payment method has been changed.'),
'pretix.event.order.expire_warning_sent': _('An email has been sent with a warning that the order is about to expire.'),
'pretix.event.order.expire_warning_sent': _('An email has been sent with a warning that the order is about '
'to expire.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
'your account.'),
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
'from your account.'),
'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.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.voucher.added': _('The voucher has been created.'),
'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}.'),
'pretix.event.item.added': _('The product has been created.'),
'pretix.event.item.changed': _('The product has been modified.'),
'pretix.event.item.deleted': _('The product has been deleted.'),
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been modified.'),
'pretix.event.category.added': _('The category has been added.'),
'pretix.event.category.deleted': _('The category has been deleted.'),
'pretix.event.category.changed': _('The category has been modified.'),
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been modified.'),
'pretix.event.settings': _('The event settings have been changed.'),
'pretix.event.tickets.settings': _('The ticket download settings have been changed.'),
'pretix.event.plugins.enabled': _('A plugin has been enabled.'),
'pretix.event.plugins.disabled': _('A plugin has been disabled.'),
'pretix.event.live.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.changed': _('The event settings have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
'pretix.event.question.option.changed': _('An answer option has been changed.'),
'pretix.event.permissions.added': _('A user has been added to the event team.'),
'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.'),
}
data = json.loads(logentry.data)
if logentry.action_type.startswith('pretix.event.item.variation'):
if 'value' not in data:
# Backwards compatibility
var = ItemVariation.objects.filter(id=data['id']).first()
if var:
data['value'] = str(var.value)
else:
data['value'] = '?'
else:
data['value'] = LazyI18nString(data['value'])
if logentry.action_type in plains:
return plains[logentry.action_type]
return plains[logentry.action_type].format_map(data)
if logentry.action_type.startswith('pretix.event.order.changed'):
return _display_order_changed(sender, logentry)
if logentry.action_type == 'pretix.user.settings.2fa.device.added':
data = json.loads(logentry.data)
return _('A new two-factor authentication device "{name}" has been added to your account.').format(
name=data['name']
)
if logentry.action_type == 'pretix.user.settings.2fa.device.deleted':
data = json.loads(logentry.data)
return _('The two-factor authentication device "{name}" has been removed from your account.').format(
name=data['name']
)
if logentry.action_type.startswith('pretix.event.payment.provider.'):
return _('The settings of a payment provider have been changed.')
if logentry.action_type.startswith('pretix.event.tickets.provider.'):
return _('The settings of a ticket output provider have been changed.')
if logentry.action_type == 'pretix.user.settings.changed':
data = json.loads(logentry.data)
text = str(_('Your account settings have been changed.'))
if 'email' in data:
text = text + ' ' + str(_('Your email address has been changed to {email}.').format(email=data['email']))

View File

@@ -9,7 +9,9 @@ from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from pretix.base.models import Event, EventPermission, Organizer
from pretix.base.models import (
Event, EventPermission, Organizer, OrganizerPermission,
)
class PermissionMiddleware(MiddlewareMixin):
@@ -24,15 +26,18 @@ class PermissionMiddleware(MiddlewareMixin):
"auth.login.2fa",
"auth.register",
"auth.forgot",
"auth.forgot.recover"
"auth.forgot.recover",
"auth.invite",
)
def process_request(self, request):
url = resolve(request.path_info)
url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'):
# This middleware should only touch the /control subpath
return
if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
@@ -79,6 +84,10 @@ class PermissionMiddleware(MiddlewareMixin):
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."))

View File

@@ -50,8 +50,8 @@ This signal is sent out to include widgets in the event dashboard. Receivers
should return a list of dictionaries, where each dictionary can have the keys:
* content (str, containing HTML)
* minimal width (int, widget width in 1/12ths of the page, default ist 3, can be
ignored on small displays)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
(quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1)
* link (str, optional, if the full widget should be a link)
@@ -66,8 +66,8 @@ This signal is sent out to include widgets in the personal user dashboard. Recei
should return a list of dictionaries, where each dictionary can have the keys:
* content (str, containing HTML)
* minimal width (int, widget width in 1/12ths of the page, default ist 3, can be
ignored on small displays)
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
(quarter of a row). May be ignored on small displays, default is "small")
* priority (int, used for ordering, higher comes first, default is 1)
* link (str, optional, if the full widget should be a link)

View File

@@ -0,0 +1,31 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %}
{% block content %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Accept an invitation" %}</h3>
<p>
{% url "control:auth.login" as loginurl %}
{% blocktrans trimmed with login_href='href="'|add:loginurl|add:'"'|safe %}
If you already have an account on this site with a different email address, you can
<a {{ login_href }}>log in</a> first and then click this link again to accept the
invitation with your existing account.
{% endblocktrans %}
</p>
{% bootstrap_form_errors form type='all' layout='inline' %}
{% csrf_token %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %}
<div class="form-group buttons">
<a href="{% url "control:auth.login" %}" class="btn btn-link">
&laquo; {% trans "Login" %}
</a>
<button type="submit" class="btn btn-primary">
{% trans "Register" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -24,7 +24,9 @@
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "charts/raphael-min.js" %}"></script>
<script type="text/javascript" src="{% static "charts/morris.js" %}"></script>
<script type="text/javascript" src="{% static "clipboard/clipboard.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/jquery.qrcode.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/clipboard.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/menu.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>

View File

@@ -5,7 +5,7 @@
<h1>{% trans "Dashboard" %}</h1>
<div class="row dashboard">
{% for w in widgets %}
<div class="col-xs-12 col-sm-{% if w.width > 6 %}12{% else %}6{% endif %} col-md-{{ w.width }}">
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
{% if w.url %}
<a href="{{ w.url }}" class="widget">
{{ w.content|safe }}

View File

@@ -0,0 +1,16 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event that uses pretix for their
ticket sales.
Event: {{ event }}
If you want to join that team, just click on the following link:
{{ url }}
If you do not want to join, you can safely ignore or delete this email.
Best regards,
Your pretix team
{% endblocktrans %}

View File

@@ -0,0 +1,16 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
you have been invited to the team of an event organizer that uses pretix
for their ticket sales.
Organizer: {{ organizer }}
If you want to join that team, just click on the following link:
{{ url }}
If you do not want to join, you can safely ignore or delete this email.
Best regards,
Your pretix team
{% endblocktrans %}

View File

@@ -0,0 +1,25 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Current issues" %}{% endblock %}
{% block inside %}
<h1>{% trans "Current issues" %}</h1>
<ul class="list-group">
{% for action in actions %}
<li class="list-group-item logentry">
<p>
<a href="{% url "control:event.requiredaction.discard" event=request.event.slug organizer=request.event.organizer.slug id=action.id %}"
class="btn btn-default btn-xs pull-right">
{% trans "Hide message" %}
</a>
<small><span class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}</small>
</p>
{{ action.display|safe }}
</li>
{% empty %}
<div class="list-group-item">
<em>{% trans "No issues. Awesome!" %}</em>
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -98,12 +98,28 @@
{% endif %}
{% for nav in nav_event %}
<li>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}>
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% endif %}
{{ nav.label }}
</a>
{% if nav.children %}
<a href="#" class="arrow">
<span class="fa arrow"></span>
</a>
<ul class="nav nav-second-level">
{% for item in nav.children %}
<li>
<a href="{{ item.url }}"
{% if item.active %}class="active"{% endif %}>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
{% endblock %}

View File

@@ -3,15 +3,54 @@
{% load eventurl %}
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>
{{ request.event.name }}
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-default btn-sm" target="_blank">
{% trans "Go to shop" %}
</a>
</h1>
<form action="{% eventurl request.event "presale:event.auth" %}" method="post" target="_blank">
<h1>
{{ request.event.name }}
{% if has_domain and not request.event.live %}
<input type="hidden" value="{{ new_session }}" name="session">
<button type="submit" class="btn btn-default btn-sm">
{% trans "Go to shop" %}
</button>
{% else %}
<a href="{% eventurl request.event "presale:event.index" %}" class="btn btn-default btn-sm" target="_blank">
{% trans "Go to shop" %}
</a>
{% endif %}
</h1>
</form>
{% if actions|length > 0 %}
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Your attention is required to resolve the following issues" %}
</h3>
</div>
<ul class="list-group">
{% for action in actions %}
<li class="list-group-item logentry">
<p>
<a href="{% url "control:event.requiredaction.discard" event=request.event.slug organizer=request.event.organizer.slug id=action.id %}"
class="btn btn-default btn-xs pull-right">
{% trans "Hide message" %}
</a>
<small><span class="fa fa-clock-o"></span> {{ action.datetime|date:"SHORT_DATETIME_FORMAT" }}</small>
</p>
{{ action.display|safe }}
</li>
{% endfor %}
</ul>
<div class="panel-footer">
<a href="{% url "control:event.requiredactions" event=request.event.slug organizer=request.event.organizer.slug %}">
{% trans "Show more" %}
</a>
</div>
</div>
{% endif %}
<div class="row dashboard">
{% for w in widgets %}
<div class="col-xs-12 col-sm-{% if w.width > 6 %}12{% else %}6{% endif %} col-md-{{ w.width }}">
<div class="widget-container widget-{{ w.display_size|default:"small" }}">
{% if w.url %}
<a href="{{ w.url }}" class="widget">
{{ w.content|safe }}
@@ -24,4 +63,43 @@
</div>
{% endfor %}
</div>
<p>&nbsp;</p>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Event logs" %}
</h3>
</div>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
{% if log.display_object %}
<span class="fa fa-flag"></span> {{ log.display_object|safe }}
{% endif %}
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
</div>
</div>
</li>
{% endfor %}
</ul>
<div class="panel-footer">
<a href="{% url "control:event.log" event=request.event.slug organizer=request.event.organizer.slug %}">
{% trans "Show more logs" %}
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,54 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Event logs" %}{% endblock %}
{% block inside %}
<h1>{% trans "Event logs" %}</h1>
<form class="form-inline helper-display-inline" action="" method="get">
<p>
<select name="user" class="form-control">
<option value="">{% trans "All actions" %}</option>
<option value="yes" {% if request.GET.user == "yes" %}selected="selected"{% endif %}>
{% trans "Team actions" %}
</option>
<option value="no" {% if request.GET.user == "no" %}selected="selected"{% endif %}>
{% trans "Customer actions" %}
</option>
{% for up in userlist %}
<option value="{{ up.user_id }}" {% if request.GET.user == up.user_id %}selected="selected"{% endif %}>
{{ up.user }}
</option>
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</p>
</form>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-2 col-sm-6 col-xs-12">
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
</div>
<div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %}
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
{% if log.display_object %}
<span class="fa fa-flag"></span> {{ log.display_object|safe }}
{% endif %}
</div>
<div class="col-lg-6 col-sm-12 col-xs-12">
{{ log.display }}
</div>
</div>
</li>
{% empty %}
<div class="list-group-item">
<em>{% trans "No results" %}</em>
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -26,7 +26,16 @@
<tbody>
{% for form in formset %}
<tr>
<td>{{ form.id }}{{ form.instance.user }}</td>
<td>
{{ form.id }}
{% if form.instance.user %}
{{ form.instance.user }}
{% else %}
{{ form.instance.invite_email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
{% endif %}
</td>
<td>{{ form.can_change_settings }}</td>
<td>{{ form.can_change_items }}</td>
<td>{{ form.can_view_orders }}</td>
@@ -37,6 +46,19 @@
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="9">
<strong>{% trans "Adding a new user" %}</strong><br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they already have a
pretix account, they will immediately be added to the event. Otherwise, they will
be sent an email with an invitation.
{% endblocktrans %}
</td>
</tr>
<tr>
<td>
<div class="row-fluid">
@@ -53,7 +75,7 @@
<td>{{ add_form.can_change_vouchers }}</td>
<td>{{ add_form.can_view_vouchers }}</td>
</tr>
</tbody>
</tfoot>
</table>
</div>
</fieldset>

View File

@@ -13,7 +13,8 @@
<div class="panel panel-default ticketoutput-panel">
<div class="panel-heading">
<a href="{% url "control:event.settings.tickets.preview" event=request.event.slug organizer=request.organizer.slug output=provider.identifier %}"
class="btn btn-default btn-sm pull-right" target="_blank">
class="btn btn-default btn-sm pull-right {% if not provider.preview_allowed %}disabled{% endif %}"
target="_blank">
{% trans "Preview" %}
</a>
<h3 class="panel-title">{{ provider.verbose_name }}</h3>

View File

@@ -1,40 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Create a new event" %}{% endblock %}
{% block content %}
<h1>{% trans "Create a new event" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.slug layout="horizontal" %}
{% bootstrap_field form.date_from layout="horizontal" %}
{% bootstrap_field form.date_to layout="horizontal" %}
{% bootstrap_field form.currency layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Display settings" %}</legend>
{% bootstrap_field sform.locales layout="horizontal" %}
{% bootstrap_field sform.locale layout="horizontal" %}
{% bootstrap_field sform.timezone layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>
{% bootstrap_field form.presale_start layout="horizontal" %}
{% bootstrap_field form.presale_end layout="horizontal" %}
</fieldset>
<p>
{% blocktrans trimmed %}
You will be able to adjust further settings in the next step.
{% endblocktrans %}
</p>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Create a new event" %}{% endblock %}
{% block content %}
<h1>{% trans "Create a new event" %} <small>{% blocktrans trimmed with step=wizard.steps.step1 %}
Step {{ step }}
{% endblocktrans %}</small></h1>
{% if has_organizer %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{{ wizard.management_form }}
{% bootstrap_form_errors form %}
{% block form %}
{% endblock %}
<div class="form-group submit-group">
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit" value="{{ wizard.steps.prev }}"
class="btn btn-default btn-lg pull-left">
{% trans "Back" %}
</button>
{% endif %}
<button type="submit" class="btn btn-primary btn-save">
{% trans "Continue" %}
</button>
</div>
</form>
{% else %}
<div class="alert alert-danger">
{% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %}
</div>
{% if request.user.is_superuser %}
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
{% trans "Create a new organizer" %}
</a>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "pretixcontrol/events/create_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.slug layout="horizontal" %}
{% bootstrap_field form.date_from layout="horizontal" %}
{% bootstrap_field form.date_to layout="horizontal" %}
{% bootstrap_field form.currency layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Display settings" %}</legend>
{% bootstrap_field form.locale layout="horizontal" %}
{% bootstrap_field form.timezone layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>
{% bootstrap_field form.presale_start layout="horizontal" %}
{% bootstrap_field form.presale_end layout="horizontal" %}
</fieldset>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "pretixcontrol/events/create_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block form %}
<p>
{% blocktrans trimmed %}
Do you want to copy over your configuration from a different event? We will copy all
products, categories, quotas, and questions as well as general event settings.
{% endblocktrans %}
</p>
<div class="alert alert-info">
<strong>
{% blocktrans trimmed %}
Please make sure to review all settings extensively. You will probably still need to change some
settings manually, e.g. date and time settings and texts that contain the event name.
{% endblocktrans %}
</strong>
</div>
{% bootstrap_field form.copy_from_event layout="horizontal" %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends "pretixcontrol/events/create_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block form %}
{% bootstrap_field form.organizer layout="horizontal" %}
{% bootstrap_field form.locales layout="horizontal" %}
{% endblock %}

View File

@@ -1,30 +0,0 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% block title %}{% trans "Create a new event" %}{% endblock %}
{% block content %}
<h1>{% trans "Create a new event" %}</h1>
{% if organizers|length == 0 %}
<div class="alert alert-info">
{% trans "You are not permitted to create new events in the name of any organizer." %}
</div>
{% else %}
<p>{% trans "Please choose the organizer of this event from the list below." %}</p>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Organizer name" %}</th>
</tr>
</thead>
<tbody>
{% for o in organizers %}
<tr>
<td><strong>
<a href="{% url "control:events.create" organizer=o.slug %}">{{ o.name }}</a>
</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -4,29 +4,43 @@
{% block inside %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.active layout="horizontal" %}
{% bootstrap_field form.category layout="horizontal" %}
{% bootstrap_field form.admission layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
{% bootstrap_field form.picture layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
{% bootstrap_field form.free_price layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
{% bootstrap_field form.require_voucher layout="horizontal" %}
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
{% bootstrap_field form.allow_cancel layout="horizontal" %}
</fieldset>
<div class="row">
<div class="col-xs-12 col-lg-10">
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.active layout="horizontal" %}
{% bootstrap_field form.category layout="horizontal" %}
{% bootstrap_field form.admission layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
{% bootstrap_field form.picture layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
{% bootstrap_field form.free_price layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
{% bootstrap_field form.require_voucher layout="horizontal" %}
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
{% bootstrap_field form.allow_cancel 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 "Product history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=item %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -7,11 +7,25 @@
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
</fieldset>
<div class="row">
<div class="col-xs-12 col-lg-10">
<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>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=category %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}

View File

@@ -36,31 +36,31 @@
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</p>
</form>
{% if not stats %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No matching answers found.
{% endblocktrans %}
</p>
{% if not items %}
<div class="row" id="question-stats">
{% if not stats %}
<div class="empty-collection col-md-10 col-xs-12">
<p>
{% trans "You need to assign the question to a product to collect answers." %}
{% blocktrans trimmed %}
No matching answers found.
{% endblocktrans %}
</p>
{% if not items %}
<p>
{% trans "You need to assign the question to a product to collect answers." %}
</p>
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
class="btn btn-primary btn-lg"><i class="fa fa-edit"></i> {% trans "Edit question" %}</a>
{% endif %}
</div>
{% else %}
<div class="row" id="question-stats">
<div class="col-md-6 col-xs-12">
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
class="btn btn-primary btn-lg"><i class="fa fa-edit"></i> {% trans "Edit question" %}</a>
{% endif %}
</div>
{% else %}
<div class="col-md-5 col-xs-12">
<div class="chart" id="question_chart" data-type="{{ question.type }}">
</div>
<script type="application/json" id="question-chart-data">{{ stats_json|safe }}</script>
</div>
<div class="col-md-6 col-xs-12">
<div class="col-md-5 col-xs-12">
<table class="table table-bordered table-hover">
<thead>
<tr>
@@ -80,6 +80,16 @@
</tbody>
</table>
</div>
{% endif %}
<div class="col-xs-12 col-lg-2">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Question history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=question %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -15,14 +15,14 @@
{% endif %}
</h1>
<div class="row" id="quota-stats">
<div class="col-md-6 col-xs-12">
<div class="col-md-5 col-xs-12">
<legend>{% trans "Usage overview" %}</legend>
<div class="chart" id="quota_chart">
</div>
<script type="application/json" id="quota-chart-data">{{ quota_chart_data|safe }}</script>
</div>
<div class="col-md-6 col-xs-12">
<div class="col-md-5 col-xs-12">
<legend>{% trans "Availability calculation" %}</legend>
<div class="row">
@@ -43,6 +43,31 @@
{% if quota.size == None %}{% trans "Infinite" %}{% else %}{{ avail.1 }}{% endif %}
</div>
</div>
{% if quota_overbooked > 0 %}
<div class="alert alert-warning">
{% blocktrans trimmed with num=quota_overbooked %}
This quota is currently overbooked by {{ num }} tickets.
{% endblocktrans %}
</div>
{% endif %}
{% if has_ignore_vouchers %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
Your event contains vouchers that affect products covered by this quota and that
allow a user to buy products even if this quota is sold out.
{% endblocktrans %}
</div>
{% endif %}
</div>
<div class="col-md-2 col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Quota history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=quota %}
</div>
</div>
</div>
{% eventsignal request.event "pretix.control.signals.quota_detail_html" quota=quota %}

View File

@@ -43,6 +43,7 @@
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
#{{ position.positionid }}
<strong>{{ position.item.name }}</strong>
{% if position.variation %}
{{ position.variation }}

View File

@@ -158,11 +158,15 @@
<div class="panel-body">
{% for line in items.positions %}
<div class="row-fluid product-row">
<div class="col-md-4 col-xs-6">
<div class="col-md-9 col-xs-6">
#{{ line.positionid }}
<strong>{{ line.item.name }}</strong>
{% if line.variation %}
{{ line.variation }}
{% endif %}
{% if line.checkins.all %}
<span class="fa fa-check" data-toggle="tooltip" title="{% blocktrans trimmed with date=line.checkins.all.0.datetime|date:'d.m.Y H:i' %}First scanned: {{ date }}{% endblocktrans %}"></span>
{% endif %}
{% if line.voucher %}
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
<a href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
@@ -184,14 +188,8 @@
</dl>
{% endif %}
</div>
<div class="col-md-2 col-xs-6 count">
{{ line.count }}
</div>
<div class="col-md-3 col-xs-6 price">
{{ event.currency }} {{ line.price|floatformat:2 }}
</div>
<div class="col-md-3 col-xs-6 price">
<strong>{{ event.currency }} {{ line.total|floatformat:2 }}</strong>
<strong>{{ event.currency }} {{ line.price|floatformat:2 }}</strong>
{% if line.tax_rate %}
<br/>
<small>{% blocktrans trimmed with rate=line.tax_rate %}

View File

@@ -99,8 +99,8 @@
<th>{{ total.num_canceled|togglesum }}</th>
<th>{{ total.num_refunded|togglesum }}</th>
<th>{{ total.num_expired|togglesum }}</th>
<th>{{ total.num_paid|togglesum }}</th>
<th>{{ total.num_pending|togglesum }}</th>
<th>{{ total.num_paid|togglesum }}</th>
<th>{{ total.num_total|togglesum }}</th>
</tr>
</tfoot>

View File

@@ -3,19 +3,131 @@
{% load bootstrap3 %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "Organizer" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.slug layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<h1>
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
class="btn btn-default">
<span class="fa fa-edit"></span>
{% trans "Edit" %}
</a>
</h1>
<div class="row">
<div class="{% if request.orgaperm.can_change_permissions %}col-lg-6{% endif %} col-xs-12">
<fieldset>
<legend>{% trans "Events" %}</legend>
{% if events|length == 0 %}
<p>
<em>{% trans "You currently do not have access to any events." %}</em>
</p>
{% else %}
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Event name" %}</th>
<th>{% trans "Start date" %}</th>
</tr>
</thead>
<tbody>
{% for e in events %}
<tr>
<td>
<strong><a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
</td>
<td>{{ e.get_date_from_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<a href="{% url "control:events.add" %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new event" %}
</a>
</fieldset>
</div>
</form>
{% 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>
<p>
{% blocktrans trimmed %}
You can use the following list to control who can create new events in the name of this
organizer and who can add more people to this list. This does <strong>not</strong>
control who has access to a particular event. You can control the access to an
event in the "Permissions" section of the event's settings. A user does not need to be
on the list here to get access to an event.
{% endblocktrans %}
</p>
{% bootstrap_formset_errors formset %}
{{ formset.management_form }}
<div class="table-responsive">
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Create events" %}</th>
<th>{% trans "Change permissions" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>
{{ form.id }}
{% if form.instance.user %}
{{ form.instance.user }}
{% else %}
{{ form.instance.invite_email }}
<span class="fa fa-envelope-o" data-toggle="tooltip"
title="{% trans "invited, pending response" %}"></span>
{% endif %}
</td>
<td>{{ form.can_create_events }}</td>
<td>{{ form.can_change_permissions }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="9">
<strong>{% trans "Adding a new user" %}</strong><br>
{% blocktrans trimmed %}
To add a new user, you can enter their email address here. If they already have a
pretix account, they will immediately be added to the team. Otherwise, they will
be sent an email with an invitation.
{% endblocktrans %}
</td>
</tr>
<tr>
<td>
<div class="row-fluid">
<div class="col-sm-12">
{% bootstrap_field add_form.user layout='inline' %}
</div>
</div>
</td>
<td>{{ add_form.can_create_events }}</td>
<td>{{ add_form.can_change_permissions }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</fieldset>
</form>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "Organizer" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.slug layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -20,7 +20,7 @@
{% for o in organizers %}
<tr>
<td><strong>
<a href="{% url "control:organizer.edit" organizer=o.slug %}">{{ o.name }}</a>
<a href="{% url "control:organizer" organizer=o.slug %}">{{ o.name }}</a>
</strong></td>
</tr>
{% endfor %}

View File

@@ -9,8 +9,7 @@
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General settings" %}</legend>
{% bootstrap_field form.givenname layout='horizontal' %}
{% bootstrap_field form.familyname layout='horizontal' %}
{% bootstrap_field form.fullname layout='horizontal' %}
{% bootstrap_field form.locale layout='horizontal' %}
</fieldset>
<fieldset>

View File

@@ -11,7 +11,7 @@
<fieldset>
<legend>{% trans "Voucher codes" %}</legend>
<div class="form-group">
<div class="col-md-6 col-md-offset-3">
<div class="col-md-7 col-sm-12 col-md-offset-3">
<div class="input-group">
<input type="text" class="form-control input-xs"
id="voucher-bulk-codes-num"
@@ -21,11 +21,16 @@
data-rng-url="{% url 'control:event.vouchers.rng' organizer=request.event.organizer.slug event=request.event.slug %}">
{% trans "Generate random codes" %}
</button>
<button type="button" class="btn btn-default btn-clipboard" data-clipboard-target="#id_codes">
<i class="fa fa-clipboard" aria-hidden="true"></i>
{% trans "Copy codes" %}
</button>
</div>
</div>
</div>
</div>
{% bootstrap_field form.codes layout="horizontal" %}
{% bootstrap_field form.max_usages layout="horizontal" %}
</fieldset>
<fieldset>
@@ -33,7 +38,15 @@
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}
{% bootstrap_field form.price layout="horizontal" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.itemvar layout="horizontal" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">

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