forked from CGM_Public/pretix_original
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4a7947062 | ||
|
|
66ce94cac3 | ||
|
|
4556914a2d | ||
|
|
52f057053e | ||
|
|
c3eb909742 | ||
|
|
8b88fb0988 | ||
|
|
8884502d19 | ||
|
|
a6a3544628 | ||
|
|
ca762083b6 | ||
|
|
550ab7de18 | ||
|
|
4919f8991c | ||
|
|
867a8132aa | ||
|
|
c661122bb6 | ||
|
|
80bd8d2039 | ||
|
|
7267496367 | ||
|
|
9dacea11dd | ||
|
|
91c48c50e5 | ||
|
|
67e5ecb931 | ||
|
|
887152a0e2 | ||
|
|
c1a76c4c18 | ||
|
|
8dacbe0fc6 | ||
|
|
a4ead5bd07 | ||
|
|
2f6e36c504 | ||
|
|
bcdb4fd000 | ||
|
|
99395c722d | ||
|
|
e28030576a | ||
|
|
455b0f2015 | ||
|
|
6da0125b7d | ||
|
|
48912bdf55 | ||
|
|
ba70ddfb76 | ||
|
|
f828fcdcab | ||
|
|
c1403207de | ||
|
|
4514bd7e53 | ||
|
|
f2378168c1 | ||
|
|
e0e3a72268 | ||
|
|
c932892dbd | ||
|
|
f03ad7c68f | ||
|
|
d3a26d8022 | ||
|
|
446698d52f | ||
|
|
69faab01b2 | ||
|
|
36d6b6f9ab | ||
|
|
ea70b5fa46 | ||
|
|
927e21e5d1 | ||
|
|
259c0cca69 | ||
|
|
11ce4c2078 | ||
|
|
76ec402fc5 | ||
|
|
df956816b4 | ||
|
|
5d431b3843 | ||
|
|
91ca4f2184 | ||
|
|
b00a0eccc6 | ||
|
|
d675ad18e0 | ||
|
|
031ed8f3cd | ||
|
|
aed78c2d69 | ||
|
|
af3e811f94 | ||
|
|
811c498080 | ||
|
|
e6d58b3b0d | ||
|
|
b7dc671028 | ||
|
|
8418eb2c6b | ||
|
|
5a882a0fae | ||
|
|
be1cbfeb91 | ||
|
|
96c61a073c | ||
|
|
64ef293ce2 | ||
|
|
55953d5b4e | ||
|
|
c63e69db5f | ||
|
|
f9646d9325 | ||
|
|
6bbdbddfaa | ||
|
|
177d46ab8d | ||
|
|
ecd90da554 | ||
|
|
2302dbade6 | ||
|
|
cbf735487f | ||
|
|
a10090b1fb | ||
|
|
babf76371e | ||
|
|
1baac6bb21 |
@@ -2,11 +2,32 @@ before_script:
|
||||
tests:
|
||||
stage: test
|
||||
script:
|
||||
- virtualenv-3.4 env
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh style
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh tests
|
||||
- XDG_CACHE_HOME=/cache bash .travis.sh doctests
|
||||
tags:
|
||||
- python3
|
||||
pypi:
|
||||
stage: release
|
||||
script:
|
||||
- cp /keys/.pypirc ~/.pypirc
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
- cd src
|
||||
- python setup.py sdist upload
|
||||
- python setup.py bdist_wheel upload
|
||||
tags:
|
||||
- python3
|
||||
only:
|
||||
- release
|
||||
artifacts:
|
||||
paths:
|
||||
- src/dist/
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- release
|
||||
|
||||
17
.travis.sh
17
.travis.sh
@@ -30,7 +30,7 @@ if [ "$1" == "tests" ]; then
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && coverage report
|
||||
py.test --rerun 5 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
@@ -39,3 +39,18 @@ if [ "$1" == "tests-cov" ]; then
|
||||
make all compress
|
||||
coverage run -m py.test --rerun 5 tests && codecov
|
||||
fi
|
||||
if [ "$1" == "plugins" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
|
||||
cd src
|
||||
python setup.py develop
|
||||
make all compress
|
||||
|
||||
pushd ~
|
||||
git clone --depth 1 https://github.com/pretix/pretix-cartshare.git
|
||||
cd pretix-cartshare
|
||||
python setup.py develop
|
||||
make
|
||||
py.test --rerun 5 tests
|
||||
popd
|
||||
|
||||
fi
|
||||
|
||||
@@ -32,6 +32,8 @@ matrix:
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.4
|
||||
env: JOB=style
|
||||
- python: 3.4
|
||||
env: JOB=plugins
|
||||
- python: 3.4
|
||||
env: JOB=tests-cov
|
||||
addons:
|
||||
|
||||
17
AUTHORS
17
AUTHORS
@@ -3,18 +3,31 @@ people who have submitted patches, reported bugs, added translations, helped
|
||||
answer newbie questions, improved the documentation, and generally made pretix
|
||||
an awesome project. Thank you all!
|
||||
|
||||
Adam K. Sumner <asumner101@gmail.com>
|
||||
Ahrdie <robert.deppe@me.com>
|
||||
Alexander Brock <Brock.Alexander@web.de>
|
||||
Ben Oswald
|
||||
Brandon Pineda
|
||||
Bolutife Lawrence
|
||||
Christian Franke <nobody@nowhere.ws>
|
||||
Christopher Dambamuromo <me@chridam.com>
|
||||
chotee <chotee@openended.eu>
|
||||
Cpt. Foo
|
||||
Daniel Rosenblüh
|
||||
Enrique Saez
|
||||
Flavia Bastos
|
||||
informancer <informancer@web.de>
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
Jakob Schnell <github@ezelo.de>
|
||||
Jan Felix Wiebe <git@jfwie.be>
|
||||
Jan Weiß
|
||||
Jason Estibeiro <jasonestibeiro@live.com>
|
||||
jlwt90
|
||||
Jonas Große Sundrup <cherti@letopolis.de>
|
||||
Kevin Nelson
|
||||
Leah Oswald
|
||||
Lukas Martini
|
||||
Nathan Mattes
|
||||
Nicole Klünder
|
||||
Marc-Pascal Clement
|
||||
Martin Gross <martin@pc-coholic.de>
|
||||
Raphael Michel <mail@raphaelmichel.de>
|
||||
Team MRMCD
|
||||
|
||||
@@ -155,6 +155,8 @@ Example::
|
||||
``admins``
|
||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||
|
||||
.. _`django-settings`:
|
||||
|
||||
Django settings
|
||||
---------------
|
||||
|
||||
@@ -179,6 +181,11 @@ Example::
|
||||
|
||||
.. WARNING:: Never set this to ``True`` in production!
|
||||
|
||||
``profile``
|
||||
Enable code profiling for a random subset of requests. Disabled by default, see
|
||||
:ref:`perf-monitoring` for details.
|
||||
|
||||
.. _`metrics-settings`:
|
||||
|
||||
Metrics
|
||||
-------
|
||||
|
||||
@@ -10,3 +10,4 @@ Contents:
|
||||
|
||||
installation/index
|
||||
config
|
||||
maintainance
|
||||
|
||||
@@ -222,6 +222,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
|
||||
@@ -255,6 +255,8 @@ Yay, you are done! You should now be able to reach pretix at https://pretix.your
|
||||
*admin@localhost* with a password of *admin*. Don't forget to change that password! Create an organizer first, then
|
||||
create an event and start selling tickets!
|
||||
|
||||
You should probably read :ref:`maintainance` next.
|
||||
|
||||
Updates
|
||||
-------
|
||||
|
||||
|
||||
99
doc/admin/maintainance.rst
Normal file
99
doc/admin/maintainance.rst
Normal file
@@ -0,0 +1,99 @@
|
||||
.. highlight:: ini
|
||||
|
||||
.. _`maintainance`:
|
||||
|
||||
Backups and Monitoring
|
||||
======================
|
||||
|
||||
If you host your own pretix instance, you also need to care about the availability
|
||||
of your service and the safety of your data yourself. This page gives you some
|
||||
information that you might need to do so properly.
|
||||
|
||||
Backups
|
||||
-------
|
||||
|
||||
There are essentially two things which you should create backups of:
|
||||
|
||||
Database
|
||||
Your SQL database (MySQL or PostgreSQL). This is critical and you should **absolutely
|
||||
always create automatic backups of your database**. There are tons of tutorials on the
|
||||
internet on how to do this, and the exact process depends on the choice of your database.
|
||||
For MySQL, see ``mysqldump`` and for PostgreSQL, see the ``pg_dump`` tool. You probably
|
||||
want to create a cronjob that does the backups for you on a regular schedule.
|
||||
|
||||
Data directory
|
||||
The data directory of your pretix configuration might contain some things that you should
|
||||
back up. If you did not specify a secret in your config file, back up the ``.secret`` text
|
||||
file in the data directory. If you lose your secret, all currently active user sessions,
|
||||
password reset links and similar things will be rendered invalid. Also, you probably want
|
||||
to backup the ``media`` subdirectory of the data directory which contains all user-uploaded
|
||||
and generated files. This includes files you could in theory regenerate (ticket downloads)
|
||||
but also files that you might be legally required to keep (invoice PDFs) or files that you
|
||||
would need to re-upload (event logos, product pictures, etc.). It is up to you if you
|
||||
create regular backups of this data, but we strongly advise you to do so. You can create
|
||||
backups e.g. using ``rsync``. There is a lot of information on the internet on how to create
|
||||
backups of folders on a Linux machine.
|
||||
|
||||
There is no need to create backups of the redis database, if you use it. We only use it for
|
||||
non-critical, temporary or cached data.
|
||||
|
||||
Uptime monitoring
|
||||
-----------------
|
||||
|
||||
To monitor whether your pretix instance is running, you can issue a GET request to
|
||||
``https://pretix.mydomain.com/healthcheck/``. This endpoint tests if the connection to the
|
||||
database, to the configured cache and to redis (if used) is working correctly. If everything
|
||||
appears to work fine, an empty response with status code ``200`` is returned.
|
||||
If there is a problem, a status code in the ``5xx`` range will be returned.
|
||||
|
||||
.. _`perf-monitoring`:
|
||||
|
||||
Performance monitoring
|
||||
----------------------
|
||||
|
||||
If you to generate detailled performance statistics of your pretix installation, there is an
|
||||
endpoint at ``https://pretix.mydomain.com/metrics`` (no slash at the end) which returns a
|
||||
number of values in the text format understood by monitoring tools like Prometheus_. This data
|
||||
is only collected and exposed if you enable it in the :ref:`metrics-settings` section of your
|
||||
pretix configuration. You can also configure basic auth credentials there to protect your
|
||||
statistics against unauthorized access. The data is temporarily collected in redis, so the
|
||||
performance impact of this feature depends on the connection to your redis database.
|
||||
|
||||
Currently, mostly response times of HTTP requests and background tasks are exposed.
|
||||
|
||||
If you want to go even further, you can set the ``profile`` option in the :ref:`django-settings`
|
||||
section to a value between 0 and 1. If you set it for example to 0.1, then 10% of your requests
|
||||
(randomly selected) will be run with cProfile_ activated. The profiling results will be saved
|
||||
to your data directory. As this might impact performance significantly and writes a lot of data
|
||||
to disk, we recommend to only enable it for a small number of requests -- and only if you are
|
||||
really interested in the results.
|
||||
|
||||
Available metrics
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
The metrics available in pretix follow the standard `metric types`_ from the Prometheus world.
|
||||
Currently, the following metrics are exported:
|
||||
|
||||
pretix_view_requests_total
|
||||
Counter. Counts requests to Django views, labeled with the resolved ``url_name``, the used
|
||||
HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_view_durations_seconds
|
||||
Histogram. Measures duration of requests to Django views, labeled with the resolved
|
||||
``url_name``, the used HTTP ``method`` and the ``status_code`` returned.
|
||||
|
||||
pretix_task_runs_total
|
||||
Counter. Counts executions of background tasks, labeled with the ``task_name`` and the
|
||||
``status``. The latter can be ``success``, ``error`` or ``expected-error``.
|
||||
|
||||
pretix_task_duration_seconds
|
||||
Histogram. Measures duration of successful background task executions, labeled with the
|
||||
``task_name``.
|
||||
|
||||
pretix_model_instances
|
||||
Gauge. Measures number of instances of a certain model within the database, labeled with
|
||||
the ``model`` name.
|
||||
|
||||
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
|
||||
.. _Prometheus: https://prometheus.io/
|
||||
.. _cProfile: https://docs.python.org/3/library/profile.html
|
||||
@@ -25,7 +25,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, checkout_confirm_messages
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
|
||||
@@ -15,6 +15,7 @@ External Dependencies
|
||||
* Python 3.4 or newer
|
||||
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
|
||||
* ``pyvenv`` for Python 3 (Debian package: ``python3-venv``)
|
||||
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
|
||||
* ``libffi`` (Debian package: ``libffi-dev``)
|
||||
* ``libssl`` (Debian package: ``libssl-dev``)
|
||||
* ``libxml2`` (Debian package ``libxml2-dev``)
|
||||
|
||||
@@ -26,9 +26,11 @@ The following plugins are from independent third-party authors, so we can make
|
||||
no statements about their stability:
|
||||
|
||||
* `esPass ticket output`_
|
||||
* `IcePay integration`_
|
||||
|
||||
.. _SEPA direct debit: https://github.com/pretix/pretix-sepadebit
|
||||
.. _Passbook/Wallet ticket output: https://github.com/pretix/pretix-passbook
|
||||
.. _Cartshare: https://github.com/pretix/pretix-cartshare
|
||||
.. _Pages: https://github.com/pretix/pretix-pages
|
||||
.. _esPass ticket output: https://github.com/esPass/pretix-espass
|
||||
.. _IcePay integration: https://github.com/chotee/pretix-icepay
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.2.2"
|
||||
|
||||
@@ -9,7 +9,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import exporter # NOQA
|
||||
from . import payment # NOQA
|
||||
from . import exporters # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup # NOQA
|
||||
from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA
|
||||
|
||||
try:
|
||||
from .celery_app import app as celery_app # NOQA
|
||||
|
||||
@@ -3,7 +3,7 @@ import tempfile
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
|
||||
@@ -2,7 +2,7 @@ from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
|
||||
@@ -8,7 +8,7 @@ from django import forms
|
||||
from django.db.models import Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import localize
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..signals import register_data_exporters, register_payment_providers
|
||||
|
||||
class OrderListExporter(BaseExporter):
|
||||
identifier = 'orderlistcsv'
|
||||
verbose_name = _('List of orders (CSV)')
|
||||
verbose_name = ugettext_lazy('List of orders (CSV)')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
|
||||
@@ -104,7 +104,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
|
||||
class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'))
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')),
|
||||
|
||||
@@ -47,10 +47,11 @@ def language(lng):
|
||||
|
||||
|
||||
class LazyLocaleException(Exception):
|
||||
def __init__(self, msg, msgargs=None):
|
||||
self.msg = msg
|
||||
self.msgargs = msgargs
|
||||
super().__init__(msg, msgargs)
|
||||
def __init__(self, *args):
|
||||
self.msg = args[0]
|
||||
self.msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
super().__init__(self.msg, self.msgargs)
|
||||
|
||||
def __str__(self):
|
||||
if self.msgargs:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild static files and language files"
|
||||
@@ -10,3 +12,7 @@ class Command(BaseCommand):
|
||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
||||
call_command('collectstatic', verbosity=1, interactive=False)
|
||||
call_command('compress', verbosity=1, interactive=False)
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
redis = django_redis.get_redis_connection("redis")
|
||||
|
||||
REDIS_KEY_PREFIX = "pretix_metrics_"
|
||||
REDIS_KEY = "pretix_metrics"
|
||||
_INF = float("inf")
|
||||
_MINUS_INF = float("-inf")
|
||||
|
||||
|
||||
def _float_to_go_string(d):
|
||||
# inspired by https://github.com/prometheus/client_python/blob/master/prometheus_client/core.py
|
||||
if d == _INF:
|
||||
return '+Inf'
|
||||
elif d == _MINUS_INF:
|
||||
return '-Inf'
|
||||
elif math.isnan(d):
|
||||
return 'NaN'
|
||||
else:
|
||||
return repr(float(d))
|
||||
|
||||
|
||||
class Metric(object):
|
||||
@@ -34,7 +52,7 @@ class Metric(object):
|
||||
if len(labels) != len(self.labelnames):
|
||||
raise ValueError("Unknown labels used: {}".format(", ".join(set(labels) - set(self.labelnames))))
|
||||
|
||||
def _construct_metric_identifier(self, metricname, labels=None):
|
||||
def _construct_metric_identifier(self, metricname, labels=None, labelnames=None):
|
||||
"""
|
||||
Constructs the scrapable metricname usable in the output format.
|
||||
"""
|
||||
@@ -42,26 +60,36 @@ class Metric(object):
|
||||
return metricname
|
||||
else:
|
||||
named_labels = []
|
||||
for labelname in self.labelnames:
|
||||
named_labels.append('{}="{}",'.format(labelname, labels[labelname]))
|
||||
for labelname in (labelnames or self.labelnames):
|
||||
named_labels.append('{}="{}"'.format(labelname, labels[labelname]))
|
||||
|
||||
return metricname + "{" + ",".join(named_labels) + "}"
|
||||
|
||||
def _inc_in_redis(self, key, amount):
|
||||
def _inc_in_redis(self, key, amount, pipeline=None):
|
||||
"""
|
||||
Increments given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.incrbyfloat(rkey, amount)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hincrbyfloat(REDIS_KEY, key, amount)
|
||||
|
||||
def _set_in_redis(self, key, value):
|
||||
def _set_in_redis(self, key, value, pipeline=None):
|
||||
"""
|
||||
Sets given key in Redis.
|
||||
"""
|
||||
rkey = REDIS_KEY_PREFIX + key
|
||||
if settings.HAS_REDIS:
|
||||
redis.set(rkey, value)
|
||||
if not pipeline:
|
||||
pipeline = redis
|
||||
pipeline.hset(REDIS_KEY, key, value)
|
||||
|
||||
def _get_redis_pipeline(self):
|
||||
if settings.HAS_REDIS:
|
||||
return redis.pipeline()
|
||||
|
||||
def _execute_redis_pipeline(self, pipeline):
|
||||
if settings.HAS_REDIS:
|
||||
return pipeline.execute()
|
||||
|
||||
|
||||
class Counter(Metric):
|
||||
@@ -124,21 +152,79 @@ class Gauge(Metric):
|
||||
self._inc_in_redis(fullmetric, amount * -1)
|
||||
|
||||
|
||||
class Histogram(Metric):
|
||||
"""
|
||||
Histogram Metric Object
|
||||
"""
|
||||
|
||||
def __init__(self, name, helpstring, labelnames=None,
|
||||
buckets=(.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, 30.0, _INF)):
|
||||
if list(buckets) != sorted(buckets):
|
||||
# This is probably an error on the part of the user,
|
||||
# so raise rather than sorting for them.
|
||||
raise ValueError('Buckets not in sorted order')
|
||||
|
||||
if buckets and buckets[-1] != _INF:
|
||||
buckets.append(_INF)
|
||||
|
||||
if len(buckets) < 2:
|
||||
raise ValueError('Must have at least two buckets')
|
||||
|
||||
self.buckets = buckets
|
||||
super().__init__(name, helpstring, labelnames)
|
||||
|
||||
def observe(self, amount, **kwargs):
|
||||
"""
|
||||
Stores a value in the histogram for the labels specified in kwargs.
|
||||
"""
|
||||
if amount < 0:
|
||||
raise ValueError("Amount must be greater than zero. Otherwise use inc().")
|
||||
|
||||
self._check_label_consistency(kwargs)
|
||||
|
||||
pipe = self._get_redis_pipeline()
|
||||
|
||||
countmetric = self._construct_metric_identifier(self.name + '_count', kwargs)
|
||||
self._inc_in_redis(countmetric, 1, pipeline=pipe)
|
||||
|
||||
summetric = self._construct_metric_identifier(self.name + '_sum', kwargs)
|
||||
self._inc_in_redis(summetric, amount, pipeline=pipe)
|
||||
|
||||
kwargs_le = dict(kwargs.items())
|
||||
for i, bound in enumerate(self.buckets):
|
||||
if amount <= bound:
|
||||
kwargs_le['le'] = _float_to_go_string(bound)
|
||||
bmetric = self._construct_metric_identifier(self.name + '_bucket', kwargs_le,
|
||||
labelnames=self.labelnames + ["le"])
|
||||
self._inc_in_redis(bmetric, 1, pipeline=pipe)
|
||||
|
||||
self._execute_redis_pipeline(pipe)
|
||||
|
||||
|
||||
def metric_values():
|
||||
"""
|
||||
Produces the scrapable textformat to be presented to the monitoring system
|
||||
Produces the the values to be presented to the monitoring system
|
||||
"""
|
||||
if not settings.HAS_REDIS:
|
||||
return ""
|
||||
metrics = defaultdict(dict)
|
||||
|
||||
metrics = {}
|
||||
# Metrics from redis
|
||||
if settings.HAS_REDIS:
|
||||
for key, value in redis.hscan_iter(REDIS_KEY):
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
for key in redis.scan_iter(match=REDIS_KEY_PREFIX + "*"):
|
||||
dkey = key.decode("utf-8")
|
||||
_, _, output_key = dkey.split("_", 2)
|
||||
value = float(redis.get(dkey).decode("utf-8"))
|
||||
# Aliases
|
||||
aliases = {
|
||||
'pretix_view_requests_total': 'pretix_view_duration_seconds_count'
|
||||
}
|
||||
for a, atarget in aliases.items():
|
||||
metrics[a] = metrics[atarget]
|
||||
|
||||
metrics[output_key] = value
|
||||
# Throwaway metrics
|
||||
for m in apps.get_models(): # Count all models
|
||||
metrics['pretix_model_instances']['{model="%s"}' % m._meta] = m.objects.count()
|
||||
|
||||
return metrics
|
||||
|
||||
@@ -146,5 +232,9 @@ def metric_values():
|
||||
"""
|
||||
Provided metrics
|
||||
"""
|
||||
http_requests_total = Counter("http_requests_total", "Total number of HTTP requests made.", ["code", "handler", "method"])
|
||||
# usage: http_requests_total.inc(code="200", handler="/foo", method="GET")
|
||||
pretix_view_duration_seconds = Histogram("pretix_view_duration_seconds", "Return time of views.",
|
||||
["status_code", "method", "url_name"])
|
||||
pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a celery task",
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["task_name"])
|
||||
|
||||
@@ -135,18 +135,30 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _parse_csp(header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v.split(' ')
|
||||
return h
|
||||
|
||||
|
||||
def _render_csp(h):
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items())
|
||||
|
||||
|
||||
def _merge_csp(a, b):
|
||||
for k, v in a.items():
|
||||
if k in b:
|
||||
a[k] += b[k]
|
||||
|
||||
for k, v in b.items():
|
||||
if k not in a:
|
||||
a[k] = b[k]
|
||||
|
||||
|
||||
class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
def _parse_csp(self, header):
|
||||
h = {}
|
||||
for part in header.split(';'):
|
||||
k, v = part.strip().split(' ', 1)
|
||||
h[k.strip()] = v
|
||||
return h
|
||||
|
||||
def _render_csp(self, h):
|
||||
return "; ".join(k + ' ' + v for k, v in h.items())
|
||||
|
||||
def process_response(self, request, resp):
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
@@ -155,23 +167,23 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
resp['X-XSS-Protection'] = '1'
|
||||
h = {
|
||||
'default-src': "{static}",
|
||||
'script-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'object-src': "'none'",
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'object-src': ["'none'"],
|
||||
# frame-src is deprecated but kept for compatibility with CSP 1.0 browsers, e.g. Safari 9
|
||||
'frame-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'child-src': '{static} https://checkout.stripe.com https://js.stripe.com',
|
||||
'style-src': "{static}",
|
||||
'connect-src': "{dynamic} https://checkout.stripe.com",
|
||||
'img-src': "{static} data: https://*.stripe.com",
|
||||
'frame-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'child-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],
|
||||
'style-src': ["{static}"],
|
||||
'connect-src': ["{dynamic}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "data:", "https://*.stripe.com"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
# this. However, we'll restrict it to HTTPS.
|
||||
'form-action': "{dynamic} https:",
|
||||
'form-action': ["{dynamic}", "https:"],
|
||||
}
|
||||
if 'Content-Security-Policy' in resp:
|
||||
h.update(self._parse_csp(resp['Content-Security-Policy']))
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
staticdomain = "'self'"
|
||||
dynamicdomain = "'self'"
|
||||
@@ -184,5 +196,5 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
else:
|
||||
staticdomain += " " + settings.SITE_URL
|
||||
dynamicdomain += " " + settings.SITE_URL
|
||||
resp['Content-Security-Policy'] = self._render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
|
||||
return resp
|
||||
|
||||
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
38
src/pretix/base/migrations/0052_auto_20170324_1506.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-24 15:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0051_auto_20170206_2027'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='invoice',
|
||||
options={'ordering': ('invoice_no',)},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='orderposition',
|
||||
options={'ordering': ('positionid', 'id'), 'verbose_name': 'Order position', 'verbose_name_plural': 'Order positions'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='max_per_order',
|
||||
field=models.IntegerField(blank=True, help_text='This product can only be bought at most this times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum times per order'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='allow_cancel',
|
||||
field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='default_price',
|
||||
field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'),
|
||||
),
|
||||
]
|
||||
@@ -122,6 +122,7 @@ class Invoice(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('event', 'invoice_no')
|
||||
ordering = ('invoice_no',)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
|
||||
@@ -111,6 +111,8 @@ class Item(LoggedModel):
|
||||
:type hide_without_voucher: bool
|
||||
:param allow_cancel: If set to ``False``, an order with this product can not be canceled by the user.
|
||||
:type allow_cancel: bool
|
||||
:param max_per_order: Maximum number of times this item can be in an order. None for unlimited.
|
||||
:type max_per_order: int
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -203,6 +205,13 @@ class Item(LoggedModel):
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
)
|
||||
max_per_order = models.IntegerField(
|
||||
verbose_name=_('Maximum amount per order'),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
|
||||
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
||||
'number of items in the whole order applies regardless.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product")
|
||||
@@ -591,7 +600,7 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
|
||||
@@ -479,6 +479,7 @@ class OrderPosition(AbstractPosition):
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
|
||||
@@ -15,26 +15,50 @@ import time
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
from pretix.base.metrics import (
|
||||
pretix_task_duration_seconds, pretix_task_runs_total,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class ProfiledTask(app.Task):
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
|
||||
if settings.PROFILING_RATE > 0 and random.random() < settings.PROFILING_RATE / 100:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
starttime = time.time()
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
profiler.disable()
|
||||
tottime = time.time() - starttime
|
||||
profiler.dump_stats(os.path.join(settings.PROFILE_DIR, '{time:.0f}_{tottime:.3f}_celery_{t}.pstat'.format(
|
||||
t=self.name, tottime=tottime, time=time.time()
|
||||
)))
|
||||
return ret
|
||||
else:
|
||||
return super().__call__(*args, **kwargs)
|
||||
t0 = time.perf_counter()
|
||||
ret = super().__call__(*args, **kwargs)
|
||||
tottime = time.perf_counter() - t0
|
||||
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_duration_seconds.observe(tottime, task_name=self.name)
|
||||
return ret
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||
if settings.METRICS_ENABLED:
|
||||
expected = False
|
||||
for t in self.throws:
|
||||
if isinstance(exc, t):
|
||||
expected = True
|
||||
break
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="expected-error" if expected else "error")
|
||||
|
||||
return super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||
|
||||
def on_success(self, retval, task_id, args, kwargs):
|
||||
if settings.METRICS_ENABLED:
|
||||
pretix_task_runs_total.inc(1, task_name=self.name, status="success")
|
||||
|
||||
return super().on_success(retval, task_id, args, kwargs)
|
||||
|
||||
|
||||
class TransactionAwareTask(ProfiledTask):
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, Item, ItemVariation, Voucher,
|
||||
)
|
||||
@@ -33,6 +33,7 @@ error_messages = {
|
||||
'in_part': _('Some of the products you selected are no longer available in '
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
@@ -115,7 +116,7 @@ class CartManager:
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(error_messages['max_items'], (self.event.settings.max_items_per_order,))
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
|
||||
def _check_item_constraints(self, op):
|
||||
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
@@ -131,6 +132,25 @@ class CartManager:
|
||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.max_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations
|
||||
if isinstance(_op, self.AddOperation) and _op.item == op.item]) +
|
||||
op.count -
|
||||
len([1 for _op in self._operations
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
if new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
price = item.default_price if variation is None else (
|
||||
@@ -143,7 +163,7 @@ class CartManager:
|
||||
if not isinstance(custom_price, Decimal):
|
||||
custom_price = Decimal(custom_price.replace(",", "."))
|
||||
if custom_price > 100000000:
|
||||
return error_messages['price_too_high']
|
||||
raise CartError(error_messages['price_too_high'])
|
||||
if self.event.settings.display_net_prices:
|
||||
custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100)
|
||||
price = max(custom_price, price)
|
||||
@@ -353,7 +373,7 @@ class CartManager:
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -362,33 +382,35 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None) ->
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:raises CartError: On any error that occured
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.add_new_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.add_new_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None) -> None:
|
||||
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number)
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
@@ -45,6 +45,8 @@ error_messages = {
|
||||
'meantime. Please see below for details.'),
|
||||
'internal': _("An internal error occured, please try again."),
|
||||
'empty': _("Your cart is empty."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the "
|
||||
"surplus items from your cart."),
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
@@ -206,8 +208,10 @@ def _check_date(event: Event, now_dt: datetime):
|
||||
|
||||
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition]):
|
||||
err = None
|
||||
errargs = None
|
||||
_check_date(event, now_dt)
|
||||
|
||||
products_seen = Counter()
|
||||
for i, cp in enumerate(positions):
|
||||
if not cp.item.active or (cp.variation and not cp.variation.active):
|
||||
err = err or error_messages['unavailable']
|
||||
@@ -215,6 +219,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
continue
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
products_seen[cp.item] += 1
|
||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||
err = error_messages['max_items_per_product']
|
||||
errargs = {'max': cp.item.max_per_order,
|
||||
'product': cp.item.name}
|
||||
cp.delete() # Sorry!
|
||||
break
|
||||
|
||||
if cp.voucher:
|
||||
redeemed_in_carts = CartPosition.objects.filter(
|
||||
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
|
||||
@@ -286,7 +298,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
else:
|
||||
cp.delete() # Sorry!
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
raise OrderError(err, errargs)
|
||||
|
||||
|
||||
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
|
||||
@@ -378,37 +390,36 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
if not order.invoices.exists():
|
||||
generate_invoice(order)
|
||||
|
||||
with language(order.locale):
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
if order.total == Decimal('0.00'):
|
||||
mailtext = event.settings.mail_text_order_free
|
||||
else:
|
||||
mailtext = event.settings.mail_text_order_placed
|
||||
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
try:
|
||||
invoice_name = order.invoice_address.name
|
||||
invoice_company = order.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
mailtext,
|
||||
{
|
||||
'total': LazyNumber(order.total),
|
||||
'currency': event.currency,
|
||||
'date': LazyDate(order.expires),
|
||||
'event': event.name,
|
||||
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
|
||||
'order': order.code,
|
||||
'secret': order.secret
|
||||
}),
|
||||
'paymentinfo': str(pprov.order_pending_mail_render(order)),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
},
|
||||
event, locale=order.locale
|
||||
)
|
||||
|
||||
return order.id
|
||||
|
||||
@@ -659,13 +670,14 @@ class OrderChangeManager:
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
email: str=None, locale: str=None, address: int=None, meta_info: dict=None):
|
||||
try:
|
||||
with language(locale):
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
try:
|
||||
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info)
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
return OrderError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
|
||||
125
src/pretix/base/services/update_check.py
Normal file
125
src/pretix/base/services/update_check.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _, ugettext_noop
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def run_update_check(sender, **kwargs):
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_last or now() - gs.settings.update_check_last > timedelta(hours=23):
|
||||
update_check.apply_async()
|
||||
|
||||
|
||||
@app.task
|
||||
def update_check():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_perform:
|
||||
return
|
||||
|
||||
if not gs.settings.update_check_id:
|
||||
gs.settings.set('update_check_id', uuid.uuid4().hex)
|
||||
|
||||
if 'runserver' in sys.argv:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'development'
|
||||
})
|
||||
return
|
||||
|
||||
check_payload = {
|
||||
'id': gs.settings.get('update_check_id'),
|
||||
'version': __version__,
|
||||
'events': {
|
||||
'total': Event.objects.count(),
|
||||
'live': Event.objects.filter(live=True).count(),
|
||||
},
|
||||
'plugins': [
|
||||
{
|
||||
'name': p.module,
|
||||
'version': p.version
|
||||
} for p in get_all_plugins()
|
||||
]
|
||||
}
|
||||
try:
|
||||
r = requests.post('https://pretix.eu/.update_check/', json=check_payload)
|
||||
gs.settings.set('update_check_last', now())
|
||||
if r.status_code != 200:
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'http_error'
|
||||
})
|
||||
else:
|
||||
rdata = r.json()
|
||||
update_available = rdata['version']['updatable'] or any(p['updatable'] for p in rdata['plugins'].values())
|
||||
gs.settings.set('update_check_result_warning', update_available)
|
||||
if update_available and rdata != gs.settings.update_check_result:
|
||||
send_update_notification_email()
|
||||
gs.settings.set('update_check_result', rdata)
|
||||
except requests.RequestException:
|
||||
gs.settings.set('update_check_last', now())
|
||||
gs.settings.set('update_check_result', {
|
||||
'error': 'unavailable'
|
||||
})
|
||||
|
||||
|
||||
def send_update_notification_email():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.update_check_email:
|
||||
return
|
||||
|
||||
mail(
|
||||
gs.settings.update_check_email,
|
||||
_('pretix update available'),
|
||||
LazyI18nString.from_gettext(
|
||||
ugettext_noop(
|
||||
'Hi!\n\nAn update is available for pretix or for one of the plugins you installed in your '
|
||||
'pretix installation. Please click on the following link for more information:\n\n {url} \n\n'
|
||||
'You can always find information on the latest updates on the pretix.eu blog:\n\n'
|
||||
'https://pretix.eu/about/en/blog/'
|
||||
'\n\nBest,\n\nyour pretix developers'
|
||||
)
|
||||
),
|
||||
{
|
||||
'url': build_absolute_uri('control:global.update')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def check_result_table():
|
||||
gs = GlobalSettingsObject()
|
||||
res = gs.settings.update_check_result
|
||||
if not res:
|
||||
return {
|
||||
'error': 'no_result'
|
||||
}
|
||||
|
||||
if 'error' in res:
|
||||
return res
|
||||
|
||||
table = []
|
||||
table.append(('pretix', __version__, res['version']['latest'], res['version']['updatable']))
|
||||
for p in get_all_plugins():
|
||||
if p.module in res['plugins']:
|
||||
pdata = res['plugins'][p.module]
|
||||
table.append((_('Plugin: %s') % p.name, p.version, pdata['latest'], pdata['updatable']))
|
||||
else:
|
||||
table.append((_('Plugin: %s') % p.name, p.version, '?', False))
|
||||
|
||||
return table
|
||||
@@ -351,6 +351,34 @@ Your {event} team"""))
|
||||
'frontpage_text': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'update_check_ack': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_email': {
|
||||
'default': '',
|
||||
'type': str
|
||||
},
|
||||
'update_check_perform': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_result': {
|
||||
'default': None,
|
||||
'type': dict
|
||||
},
|
||||
'update_check_result_warning': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'update_check_last': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'update_check_id': {
|
||||
'default': None,
|
||||
'type': str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
.header h1 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 a {
|
||||
@@ -117,7 +117,7 @@
|
||||
<tr>
|
||||
<td class="header" background="">
|
||||
{% if event %}
|
||||
<h1><a href="{% eventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
<h1><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a></h1>
|
||||
{% else %}
|
||||
<h1><a href="{{ site_url }}" target="_blank">{{ site }}</a></h1>
|
||||
{% endif %}
|
||||
@@ -141,7 +141,7 @@
|
||||
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
|
||||
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
|
||||
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
|
||||
<a href="{% eventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
|
||||
{% trans "View order details" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ ALLOWED_TAGS = [
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'div',
|
||||
'span'
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
@@ -33,6 +35,9 @@ ALLOWED_ATTRIBUTES = {
|
||||
'acronym': ['title'],
|
||||
'table': ['width'],
|
||||
'td': ['width', 'align'],
|
||||
'div': ['class'],
|
||||
'p': ['class'],
|
||||
'span': ['class'],
|
||||
}
|
||||
|
||||
|
||||
@@ -41,5 +46,9 @@ def rich_text(text: str, **kwargs):
|
||||
"""
|
||||
Processes markdown and cleans HTML in a text input.
|
||||
"""
|
||||
body_md = bleach.linkify(bleach.clean(markdown.markdown(text), tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES))
|
||||
body_md = bleach.linkify(bleach.clean(
|
||||
markdown.markdown(text),
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
))
|
||||
return mark_safe(body_md)
|
||||
|
||||
@@ -9,7 +9,6 @@ from django.shortcuts import redirect, render
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import casual_reads
|
||||
|
||||
logger = logging.getLogger('pretix.base.async')
|
||||
|
||||
@@ -32,11 +31,10 @@ class AsyncAction:
|
||||
return JsonResponse(data)
|
||||
else:
|
||||
if res.ready():
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
return redirect(self.get_check_url(res.id, False))
|
||||
|
||||
def get_success_url(self, value):
|
||||
@@ -66,25 +64,24 @@ class AsyncAction:
|
||||
'ready': ready
|
||||
}
|
||||
if ready:
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
smes = self.get_success_message(res.info)
|
||||
if smes:
|
||||
messages.success(self.request, smes)
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_success_url(res.info),
|
||||
'message': str(self.get_success_message(res.info))
|
||||
})
|
||||
else:
|
||||
messages.error(self.request, self.get_error_message(res.info))
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
smes = self.get_success_message(res.info)
|
||||
if smes:
|
||||
messages.success(self.request, smes)
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_success_url(res.info),
|
||||
'message': str(self.get_success_message(res.info))
|
||||
})
|
||||
else:
|
||||
messages.error(self.request, self.get_error_message(res.info))
|
||||
# TODO: Do not store message if the ajax client states that it will not redirect
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
return data
|
||||
|
||||
def get_result(self, request):
|
||||
@@ -93,11 +90,10 @@ class AsyncAction:
|
||||
return JsonResponse(self._return_ajax_result(res, timeout=0.25))
|
||||
else:
|
||||
if res.ready():
|
||||
with casual_reads():
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
if res.successful() and not isinstance(res.info, Exception):
|
||||
return self.success(res.info)
|
||||
else:
|
||||
return self.error(res.info)
|
||||
return render(request, 'pretixpresale/waiting.html')
|
||||
|
||||
def success(self, value):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.core import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
from ..models import User
|
||||
@@ -6,4 +8,18 @@ from ..models import User
|
||||
def healthcheck(request):
|
||||
# Perform a simple DB query to see that DB access works
|
||||
User.objects.exists()
|
||||
|
||||
# Test if redis access works
|
||||
if settings.HAS_REDIS:
|
||||
import django_redis
|
||||
|
||||
redis = django_redis.get_redis_connection("redis")
|
||||
redis.set("_healthcheck", 1)
|
||||
if not redis.exists("_healthcheck"):
|
||||
return HttpResponse("Redis not available.", status=503)
|
||||
|
||||
cache.cache.set("_healthcheck", "1")
|
||||
if not cache.cache.get("_healthcheck") == "1":
|
||||
return HttpResponse("Cache not available.", status=503)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import base64
|
||||
import hmac
|
||||
|
||||
from django.conf import settings
|
||||
@@ -26,7 +27,7 @@ def serve_metrics(request):
|
||||
if method.lower() != "basic":
|
||||
return unauthed_response()
|
||||
|
||||
user, passphrase = credentials.strip().decode("base64").split(":", 1)
|
||||
user, passphrase = base64.b64decode(credentials.strip()).decode().split(":", 1)
|
||||
|
||||
if not hmac.compare_digest(user, settings.METRICS_USER):
|
||||
return unauthed_response()
|
||||
@@ -37,9 +38,10 @@ def serve_metrics(request):
|
||||
m = metrics.metric_values()
|
||||
|
||||
output = []
|
||||
for metric, value in m:
|
||||
output.append("{} {}".format(metric, str(value)))
|
||||
for metric, sub in m.items():
|
||||
for label, value in sub.items():
|
||||
output.append("{}{} {}".format(metric, label, str(value)))
|
||||
|
||||
content = "\n".join(output)
|
||||
content = "\n".join(output) + "\n"
|
||||
|
||||
return HttpResponse(content)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from .signals import html_head, nav_event, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
@@ -46,4 +50,18 @@ def contextprocessor(request):
|
||||
ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS')
|
||||
ctx['js_locale'] = get_moment_locale()
|
||||
|
||||
if settings.DEBUG and 'runserver' not in sys.argv:
|
||||
ctx['debug_warning'] = True
|
||||
elif 'runserver' in sys.argv:
|
||||
ctx['development_warning'] = True
|
||||
|
||||
ctx['warning_update_available'] = False
|
||||
ctx['warning_update_check_active'] = False
|
||||
if request.user.is_superuser:
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.update_check_result_warning:
|
||||
ctx['warning_update_available'] = True
|
||||
if not gs.settings.update_check_ack and 'runserver' not in sys.argv:
|
||||
ctx['warning_update_check_active'] = True
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -197,7 +197,8 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
locales = forms.MultipleChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
label=_("Available langauges"),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
label=_("Available languages"),
|
||||
)
|
||||
locale = forms.ChoiceField(
|
||||
choices=settings.LANGUAGES,
|
||||
@@ -259,7 +260,7 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Allow user to cancel unpaid orders"),
|
||||
help_text=_("If unchecked, users cannot cancel orders by themselves"),
|
||||
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextInput
|
||||
|
||||
@@ -32,3 +33,26 @@ class GlobalSettingsForm(SettingsForm):
|
||||
for key, value in response.items():
|
||||
# We need to be this explicit, since OrderedDict.update does not retain ordering
|
||||
self.fields[key] = value
|
||||
|
||||
|
||||
class UpdateSettingsForm(SettingsForm):
|
||||
update_check_perform = forms.BooleanField(
|
||||
required=False,
|
||||
label=_("Perform update checks"),
|
||||
help_text=_("During the update check, pretix will report an anonymous, unique installation ID, "
|
||||
"the current version of pretix and your installed plugins and the number of active and "
|
||||
"inactive events in your installation to servers operated by the pretix developers. We "
|
||||
"will only store anonymous data, never any IP adresses and we will not know who you are "
|
||||
"or where to find your instance. You can disable this behaviour here at any time.")
|
||||
)
|
||||
update_check_email = forms.EmailField(
|
||||
required=False,
|
||||
label=_("E-mail notifications"),
|
||||
help_text=_("We will notify you at this address if we detect that a new update is available. This "
|
||||
"address will not be transmitted to pretix.eu, the emails will be sent by this server "
|
||||
"locally.")
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = GlobalSettingsObject()
|
||||
super().__init__(*args, obj=self.obj, **kwargs)
|
||||
|
||||
@@ -117,7 +117,18 @@ class ItemCreateForm(I18nModelForm):
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.cleaned_data.get('copy_from'):
|
||||
self.instance.category = self.cleaned_data['copy_from'].category
|
||||
self.instance.description = self.cleaned_data['copy_from'].description
|
||||
self.instance.active = self.cleaned_data['copy_from'].active
|
||||
self.instance.available_from = self.cleaned_data['copy_from'].available_from
|
||||
self.instance.available_until = self.cleaned_data['copy_from'].available_until
|
||||
self.instance.require_voucher = self.cleaned_data['copy_from'].require_voucher
|
||||
self.instance.hide_without_voucher = self.cleaned_data['copy_from'].hide_without_voucher
|
||||
self.instance.allow_cancel = self.cleaned_data['copy_from'].allow_cancel
|
||||
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
if self.cleaned_data.get('has_variations'):
|
||||
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
|
||||
for variation in self.cleaned_data['copy_from'].variations.all():
|
||||
@@ -128,8 +139,9 @@ class ItemCreateForm(I18nModelForm):
|
||||
item=instance, value=__('Standard')
|
||||
)
|
||||
|
||||
for question in Question.objects.filter(items=self.cleaned_data.get('copy_from')):
|
||||
question.items.add(instance)
|
||||
if self.cleaned_data.get('copy_from'):
|
||||
for question in self.cleaned_data['copy_from'].questions.all():
|
||||
question.items.add(instance)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -167,7 +179,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'available_until',
|
||||
'require_voucher',
|
||||
'hide_without_voucher',
|
||||
'allow_cancel'
|
||||
'allow_cancel',
|
||||
'max_per_order'
|
||||
]
|
||||
widgets = {
|
||||
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if warning_update_available %}
|
||||
<li>
|
||||
<a href="{% url 'control:global.update' %}" class="danger">
|
||||
<i class="fa fa-bell"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{% url 'control:user.settings' %}">
|
||||
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
|
||||
@@ -141,7 +148,8 @@
|
||||
</li>
|
||||
{% if request.user.is_superuser %}
|
||||
<li>
|
||||
<a href="{% url 'control:global-settings' %}" {% if "global-settings" in url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.settings' %}"
|
||||
{% if "global.settings" in url_name %}class="active"{% endif %}>
|
||||
<i class="fa fa-wrench fa-fw"></i>
|
||||
{% trans "Global settings" %}
|
||||
</a>
|
||||
@@ -173,6 +181,25 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if warning_update_check_active %}
|
||||
<div class="alert alert-info">
|
||||
<a href="{% url "control:global.update" %}">
|
||||
{% blocktrans trimmed %}
|
||||
Starting with version 1.2.0, pretix automatically checks for updates in the background.
|
||||
During this check, anonymous data is transmitted to servers operated by pretix'
|
||||
developers. Click on this message to find out more, disable this feature or enter your
|
||||
email address to get notified via email if a new update arrives. This message will
|
||||
disappear once you clicked it.
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if debug_warning %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
@@ -182,6 +209,9 @@
|
||||
powered by <a {{ a_attr }}>pretix</a>
|
||||
{% endblocktrans %}
|
||||
{% endwith %}
|
||||
{% if development_warning %}
|
||||
<span class="text-warning">· {% trans "running in development mode" %}</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
<td>{{ add_form.can_view_orders }}</td>
|
||||
<td>{{ add_form.can_change_orders }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
<td>{{ add_form.can_view_vouchers }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block title %}{% trans "Global settings" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Global settings" %}</h1>
|
||||
{% block inner %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block title %}{% trans "Global settings" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Global settings" %}</h1>
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "global.settings" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.settings' %}">
|
||||
{% trans "General" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "global.update" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:global.update' %}">
|
||||
{% trans "Update check" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% block inner %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "pretixcontrol/global_settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block inner %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Update check results" %}</legend>
|
||||
{% if not gs.settings.update_check_perform %}
|
||||
<div class="alert alert-warning">
|
||||
{% trans "Update checks are disabled." %}
|
||||
</div>
|
||||
{% elif not gs.settings.update_check_last %}
|
||||
<div class="alert alert-info">
|
||||
{% trans "No update check has been performed yet since the last update of this installation. Update checks are performed on a daily basis if your cronjob is set up properly." %}
|
||||
</div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% elif "error" in gs.settings.update_check_result %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The last update check was not successful." %}
|
||||
{% if gs.settings.update_check_result.error == "http_error" %}
|
||||
{% trans "The pretix.eu server returned an error code." %}
|
||||
{% elif gs.settings.update_check_result.error == "unavailable" %}
|
||||
{% trans "The pretix.eu server could not be reached." %}
|
||||
{% elif gs.settings.update_check_result.error == "development" %}
|
||||
{% trans "This installation appears to be a development installation." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed with date=gs.settings.update_check_last|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Last updated: {{ date }}
|
||||
{% endblocktrans %}
|
||||
<button type="submit" name="trigger" value="1" class="btn btn-default btn-xs">
|
||||
{% trans "Check for updates now" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Component" %}</th>
|
||||
<th>{% trans "Installed version" %}</th>
|
||||
<th>{% trans "Latest version" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in tbl %}
|
||||
<tr class="{% if row.3 %}danger{% elif row.2 == "?" %}warning{% else %}success{% endif %}">
|
||||
<td>{{ row.0 }}</td>
|
||||
<td>{{ row.1 }}</td>
|
||||
<td>{{ row.2 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Update check settings" %}</legend>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,7 @@
|
||||
<legend>{% trans "Availability" %}</legend>
|
||||
{% bootstrap_field form.available_from layout="horizontal" %}
|
||||
{% bootstrap_field form.available_until layout="horizontal" %}
|
||||
{% bootstrap_field form.max_per_order layout="horizontal" %}
|
||||
{% bootstrap_field form.require_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.allow_cancel layout="horizontal" %}
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product categories" %}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -44,6 +44,7 @@
|
||||
<a href="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
<tr>
|
||||
<th>{% trans "Product name" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -53,6 +53,7 @@
|
||||
<a href="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm {% if forloop.revcounter0 == 0 %}disabled{% endif %}"><i class="fa fa-arrow-down"></i></a>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
<tr>
|
||||
<th>{% trans "Question" %}</th>
|
||||
<th>{% trans "Type" %}</th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<th>{% trans "Products" %}</th>
|
||||
<th>{% trans "Total capacity" %}</th>
|
||||
<th>{% trans "Capacity left" %}</th>
|
||||
<th></th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<form class="form-inline helper-display-inline"
|
||||
action="{% url "control:event.orders.go" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
<div class="input-group">
|
||||
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}">
|
||||
<input type="text" name="code" class="form-control" placeholder="{% trans "Order code" %}" autofocus>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit">{% trans "Go!" %}</button>
|
||||
</span>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Enabled devices" %}</h3>
|
||||
<h3 class="panel-title">{% trans "Registered devices" %}</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for d in devices %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<form class="form-inline helper-display-inline" action="" method="get">
|
||||
<p>
|
||||
<input type="text" name="search" class="form-control" placeholder="{% trans "Search voucher" %}"
|
||||
value="{{ request.GET.search }}">
|
||||
value="{{ request.GET.search }}" autofocus>
|
||||
<input type="text" name="tag" class="form-control" placeholder="{% trans "Filter by tag" %}"
|
||||
value="{{ request.GET.tag }}">
|
||||
<select name="status" class="form-control">
|
||||
|
||||
@@ -14,9 +14,10 @@ urlpatterns = [
|
||||
url(r'^forgot$', auth.Forgot.as_view(), name='auth.forgot'),
|
||||
url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'),
|
||||
url(r'^$', dashboards.user_index, name='index'),
|
||||
url(r'^settings/$', global_settings.GlobalSettingsView.as_view(), name='global-settings'),
|
||||
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'),
|
||||
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'),
|
||||
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'),
|
||||
url(r'^settings$', user.UserSettings.as_view(), name='user.settings'),
|
||||
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
|
||||
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
|
||||
|
||||
@@ -33,5 +33,5 @@ class ChartContainingView:
|
||||
def get(self, request, *args, **kwargs):
|
||||
resp = super().get(request, *args, **kwargs)
|
||||
# required by raphael.js
|
||||
resp['Content-Security-Policy'] = "script-src {static} 'unsafe-eval'; style-src {static} 'unsafe-inline'"
|
||||
resp['Content-Security-Policy'] = "script-src 'unsafe-eval'; style-src 'unsafe-inline'"
|
||||
return resp
|
||||
|
||||
@@ -98,6 +98,7 @@ class EventUpdate(EventPermissionRequiredMixin, UpdateView):
|
||||
event.presale_end = self.reset_timezone(zone, event.presale_end)
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return self.form_invalid(form)
|
||||
|
||||
@staticmethod
|
||||
@@ -455,6 +456,10 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
|
||||
form.prepare_fields()
|
||||
return form
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
success = True
|
||||
@@ -487,7 +492,7 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return redirect(self.get_success_url())
|
||||
else:
|
||||
return self.get(request)
|
||||
return self.form_invalid(form)
|
||||
|
||||
@cached_property
|
||||
def provider_forms(self) -> list:
|
||||
@@ -565,7 +570,7 @@ class EventPermissions(EventPermissionRequiredMixin, TemplateView):
|
||||
try:
|
||||
mail(
|
||||
instance.invite_email,
|
||||
_('Account information changed'),
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'user': self,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import reverse
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
|
||||
from pretix.control.forms.global_settings import GlobalSettingsForm
|
||||
from pretix.base.services.update_check import check_result_table, update_check
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.control.forms.global_settings import (
|
||||
GlobalSettingsForm, UpdateSettingsForm,
|
||||
)
|
||||
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
||||
|
||||
|
||||
@@ -21,4 +25,34 @@ class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:global-settings')
|
||||
return reverse('control:global.settings')
|
||||
|
||||
|
||||
class UpdateCheckView(AdministratorPermissionRequiredMixin, FormView):
|
||||
template_name = 'pretixcontrol/global_update.html'
|
||||
form_class = UpdateSettingsForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if 'trigger' in request.POST:
|
||||
update_check.apply()
|
||||
return redirect(self.get_success_url())
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save()
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes have not been saved, see below for errors.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['gs'] = GlobalSettingsObject()
|
||||
ctx['gs'].settings.set('update_check_ack', True)
|
||||
ctx['tbl'] = check_result_table()
|
||||
return ctx
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:global.update')
|
||||
|
||||
@@ -156,6 +156,10 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView):
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class CategoryCreate(EventPermissionRequiredMixin, CreateView):
|
||||
model = ItemCategory
|
||||
@@ -178,6 +182,10 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView):
|
||||
form.instance.log_action('pretix.event.category.added', data=dict(form.cleaned_data), user=self.request.user)
|
||||
return ret
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class CategoryList(ListView):
|
||||
model = ItemCategory
|
||||
@@ -479,6 +487,10 @@ class QuestionUpdate(EventPermissionRequiredMixin, QuestionMixin, UpdateView):
|
||||
'event': self.request.event.slug,
|
||||
})
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
model = Question
|
||||
@@ -501,6 +513,10 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
|
||||
def get_object(self, **kwargs):
|
||||
return None
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data.get('type') in ('M', 'C'):
|
||||
@@ -592,6 +608,10 @@ class QuotaCreate(EventPermissionRequiredMixin, QuotaEditorMixin, CreateView):
|
||||
form.instance.log_action('pretix.event.quota.added', user=self.request.user, data=dict(form.cleaned_data))
|
||||
return ret
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class QuotaView(ChartContainingView, DetailView):
|
||||
model = Quota
|
||||
@@ -699,6 +719,10 @@ class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView):
|
||||
'quota': self.object.pk
|
||||
})
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class QuotaDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
model = Quota
|
||||
@@ -766,15 +790,6 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
if form.cleaned_data['copy_from']:
|
||||
form.instance.category = form.cleaned_data['copy_from'].category
|
||||
form.instance.description = form.cleaned_data['copy_from'].description
|
||||
form.instance.active = form.cleaned_data['copy_from'].active
|
||||
form.instance.available_from = form.cleaned_data['copy_from'].available_from
|
||||
form.instance.available_until = form.cleaned_data['copy_from'].available_until
|
||||
form.instance.require_voucher = form.cleaned_data['copy_from'].require_voucher
|
||||
form.instance.hide_without_voucher = form.cleaned_data['copy_from'].hide_without_voucher
|
||||
form.instance.allow_cancel = form.cleaned_data['copy_from'].allow_cancel
|
||||
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.event.item.added', user=self.request.user, data={
|
||||
@@ -794,6 +809,10 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
kwargs.update({'instance': newinst})
|
||||
return kwargs
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateView):
|
||||
form_class = ItemUpdateForm
|
||||
@@ -822,6 +841,10 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
|
||||
CachedTicket.objects.filter(order_position__item=self.item).delete()
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_change_items'
|
||||
@@ -878,6 +901,7 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
|
||||
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return redirect(self.get_success_url())
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
|
||||
@@ -99,7 +99,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
try:
|
||||
mail(
|
||||
instance.invite_email,
|
||||
_('Account information changed'),
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation_organizer.txt',
|
||||
{
|
||||
'user': self,
|
||||
|
||||
@@ -155,6 +155,10 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView):
|
||||
'device': dev.pk
|
||||
}))
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/user/2fa_delete.html'
|
||||
@@ -232,7 +236,12 @@ class User2FADeviceConfirmU2FView(RecentAuthenticationRequiredMixin, TemplateVie
|
||||
_('A new two-factor authentication device has been added to your account.')
|
||||
])
|
||||
|
||||
messages.success(request, _('The device has been verified and can now be used.'))
|
||||
note = ''
|
||||
if not self.request.user.require_2fa:
|
||||
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
|
||||
'account using the buttons below to make a second factor required for logging '
|
||||
'into your accont.'))
|
||||
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
except Exception:
|
||||
messages.error(request, _('The registration could not be completed. Please try again.'))
|
||||
@@ -275,7 +284,12 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
|
||||
_('A new two-factor authentication device has been added to your account.')
|
||||
])
|
||||
|
||||
messages.success(request, _('The device has been verified and can now be used.'))
|
||||
note = ''
|
||||
if not self.request.user.require_2fa:
|
||||
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your '
|
||||
'account using the buttons below to make a second factor required for logging '
|
||||
'into your accont.'))
|
||||
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
else:
|
||||
messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
|
||||
|
||||
@@ -215,7 +215,7 @@ class VoucherCreate(EventPermissionRequiredMixin, CreateView):
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.event = self.request.event
|
||||
messages.success(self.request, _('The new voucher has been created.'))
|
||||
messages.success(self.request, _('The new voucher has been created: {code}').format(code=form.instance.code))
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.voucher.added', data=dict(form.cleaned_data), user=self.request.user)
|
||||
return ret
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection, transaction
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
class DummyRollbackException(Exception):
|
||||
@@ -29,38 +28,9 @@ def rolledback_transaction():
|
||||
raise Exception('Invalid state, should have rolled back.')
|
||||
|
||||
|
||||
if 'mysql' in settings.DATABASES['default']['ENGINE'] and settings.DATABASE_IS_GALERA:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def casual_reads():
|
||||
"""
|
||||
When pretix runs with a MySQL galera cluster as a database backend, we can run into the
|
||||
following problem:
|
||||
|
||||
* A celery thread starts a transaction, creates an object and commits the transaction.
|
||||
It then returns the object ID into celery's result store (e.g. redis)
|
||||
|
||||
* A web thread pulls the object ID from the result store, but cannot access the object
|
||||
yet as the transaction is not yet committed everywhere.
|
||||
|
||||
This sets the wsrep_sync_wait variable to deal with this problem.
|
||||
|
||||
See also:
|
||||
|
||||
* https://mariadb.com/kb/en/mariadb/galera-cluster-system-variables/#wsrep_sync_wait
|
||||
|
||||
* https://www.percona.com/doc/percona-xtradb-cluster/5.6/wsrep-system-index.html#wsrep_sync_wait
|
||||
"""
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SET @wsrep_sync_wait_orig = @@wsrep_sync_wait;")
|
||||
cursor.execute("SET SESSION wsrep_sync_wait = GREATEST(@wsrep_sync_wait_orig, 1);")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cursor.execute("SET SESSION wsrep_sync_wait = @wsrep_sync_wait_orig;")
|
||||
|
||||
else:
|
||||
|
||||
@contextlib.contextmanager
|
||||
def casual_reads():
|
||||
yield
|
||||
@contextlib.contextmanager
|
||||
def casual_reads():
|
||||
"""
|
||||
Kept for backwards compatibility.
|
||||
"""
|
||||
yield
|
||||
|
||||
0
src/pretix/helpers/metrics/__init__.py
Normal file
0
src/pretix/helpers/metrics/__init__.py
Normal file
34
src/pretix/helpers/metrics/middleware.py
Normal file
34
src/pretix/helpers/metrics/middleware.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import time
|
||||
|
||||
from django.urls import resolve
|
||||
|
||||
from pretix.base.metrics import pretix_view_duration_seconds
|
||||
|
||||
|
||||
class MetricsMiddleware(object):
|
||||
blacklist = (
|
||||
'/healthcheck/',
|
||||
'/jsi18n/',
|
||||
'/metrics',
|
||||
)
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
# One-time configuration and initialization.
|
||||
|
||||
def __call__(self, request):
|
||||
# Code to be executed for each request before
|
||||
# the view (and later middleware) are called.
|
||||
for b in self.blacklist:
|
||||
if b in request.path:
|
||||
return self.get_response(request)
|
||||
|
||||
url = resolve(request.path_info)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
resp = self.get_response(request)
|
||||
tdiff = time.perf_counter() - t0
|
||||
pretix_view_duration_seconds.observe(tdiff, status_code=resp.status_code, method=request.method,
|
||||
url_name=url.namespace + ':' + url.url_name)
|
||||
|
||||
return resp
|
||||
@@ -26,10 +26,10 @@ class CProfileMiddleware(object):
|
||||
if settings.PROFILING_RATE > 0 and random.random() < settings.PROFILING_RATE / 100:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
starttime = time.time()
|
||||
starttime = time.perf_counter()
|
||||
response = self.get_response(request)
|
||||
profiler.disable()
|
||||
tottime = time.time() - starttime
|
||||
tottime = time.perf_counter() - starttime
|
||||
profiler.dump_stats(os.path.join(settings.PROFILE_DIR, '{time:.0f}_{tottime:.3f}_{path}.pstat'.format(
|
||||
path=request.path[1:].replace("/", "_"), tottime=tottime, time=time.time()
|
||||
)))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-03-01 20:23+0000\n"
|
||||
"POT-Creation-Date: 2017-04-01 13:41+0000\n"
|
||||
"PO-Revision-Date: 2017-01-01 20:40+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-03-01 20:23+0000\n"
|
||||
"POT-Creation-Date: 2017-04-01 13:41+0000\n"
|
||||
"PO-Revision-Date: 2017-01-18 09:42+0100\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
|
||||
@@ -6,12 +6,15 @@ from django.template.defaulttags import URLNode
|
||||
from django.utils.encoding import smart_text
|
||||
from django.utils.html import conditional_escape
|
||||
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
class EventURLNode(URLNode):
|
||||
def __init__(self, event, view_name, kwargs, asvar):
|
||||
def __init__(self, event, view_name, kwargs, asvar, absolute):
|
||||
self.event = event
|
||||
self.absolute = absolute
|
||||
super().__init__(view_name, [], kwargs, asvar)
|
||||
|
||||
def render(self, context):
|
||||
@@ -24,7 +27,10 @@ class EventURLNode(URLNode):
|
||||
event = self.event.resolve(context)
|
||||
url = ''
|
||||
try:
|
||||
url = eventreverse(event, view_name, kwargs=kwargs)
|
||||
if self.absolute:
|
||||
url = build_absolute_uri(event, view_name, kwargs=kwargs)
|
||||
else:
|
||||
url = eventreverse(event, view_name, kwargs=kwargs)
|
||||
except NoReverseMatch:
|
||||
if self.asvar is None:
|
||||
raise
|
||||
@@ -39,7 +45,7 @@ class EventURLNode(URLNode):
|
||||
|
||||
|
||||
@register.tag
|
||||
def eventurl(parser, token):
|
||||
def eventurl(parser, token, absolute=False):
|
||||
"""
|
||||
Similar to {% url %} in the same way that eventreverse() is similar to reverse().
|
||||
|
||||
@@ -68,4 +74,14 @@ def eventurl(parser, token):
|
||||
else:
|
||||
raise TemplateSyntaxError('Event urls only have keyword arguments.')
|
||||
|
||||
return EventURLNode(event, viewname, kwargs, asvar)
|
||||
return EventURLNode(event, viewname, kwargs, asvar, absolute)
|
||||
|
||||
|
||||
@register.tag
|
||||
def abseventurl(parser, token):
|
||||
"""
|
||||
Similar to {% url %} in the same way that eventreverse() is similar to reverse().
|
||||
|
||||
Returns an absolute URL.
|
||||
"""
|
||||
return eventurl(parser, token, absolute=True)
|
||||
|
||||
@@ -3,7 +3,7 @@ import io
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.exporter import BaseExporter
|
||||
from pretix.base.models import Order, OrderPosition, Question
|
||||
@@ -16,7 +16,7 @@ class BaseCheckinList(BaseExporter):
|
||||
class CSVCheckinList(BaseCheckinList):
|
||||
name = "overview"
|
||||
identifier = 'checkinlistcsv'
|
||||
verbose_name = _('Check-in list (CSV)')
|
||||
verbose_name = ugettext_lazy('Check-in list (CSV)')
|
||||
|
||||
@property
|
||||
def export_form_fields(self):
|
||||
|
||||
@@ -7,7 +7,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.db.models import Sum
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
@@ -187,43 +187,43 @@ class OverviewReport(Report):
|
||||
tstyledata.append(('FONTNAME', (0, len(tdata)), (-1, len(tdata)), 'OpenSansBd'))
|
||||
tdata.append([
|
||||
tup[0].name,
|
||||
str(tup[0].num_canceled[0]), str(tup[0].num_canceled[1]),
|
||||
str(tup[0].num_refunded[0]), str(tup[0].num_refunded[1]),
|
||||
str(tup[0].num_expired[0]), str(tup[0].num_expired[1]),
|
||||
str(tup[0].num_pending[0]), str(tup[0].num_pending[1]),
|
||||
str(tup[0].num_paid[0]), str(tup[0].num_paid[1]),
|
||||
str(tup[0].num_total[0]), str(tup[0].num_total[1]),
|
||||
str(tup[0].num_canceled[0]), localize(tup[0].num_canceled[1]),
|
||||
str(tup[0].num_refunded[0]), localize(tup[0].num_refunded[1]),
|
||||
str(tup[0].num_expired[0]), localize(tup[0].num_expired[1]),
|
||||
str(tup[0].num_pending[0]), localize(tup[0].num_pending[1]),
|
||||
str(tup[0].num_paid[0]), localize(tup[0].num_paid[1]),
|
||||
str(tup[0].num_total[0]), localize(tup[0].num_total[1]),
|
||||
])
|
||||
for item in tup[1]:
|
||||
tdata.append([
|
||||
" " + str(item.name),
|
||||
str(item.num_canceled[0]), str(item.num_canceled[1]),
|
||||
str(item.num_refunded[0]), str(item.num_refunded[1]),
|
||||
str(item.num_expired[0]), str(item.num_expired[1]),
|
||||
str(item.num_pending[0]), str(item.num_pending[1]),
|
||||
str(item.num_paid[0]), str(item.num_paid[1]),
|
||||
str(item.num_total[0]), str(item.num_total[1]),
|
||||
str(item.num_canceled[0]), localize(item.num_canceled[1]),
|
||||
str(item.num_refunded[0]), localize(item.num_refunded[1]),
|
||||
str(item.num_expired[0]), localize(item.num_expired[1]),
|
||||
str(item.num_pending[0]), localize(item.num_pending[1]),
|
||||
str(item.num_paid[0]), localize(item.num_paid[1]),
|
||||
str(item.num_total[0]), localize(item.num_total[1]),
|
||||
])
|
||||
if item.has_variations:
|
||||
for var in item.all_variations:
|
||||
tdata.append([
|
||||
" " + str(var),
|
||||
str(var.num_canceled[0]), str(var.num_canceled[1]),
|
||||
str(var.num_refunded[0]), str(var.num_refunded[1]),
|
||||
str(var.num_expired[0]), str(var.num_expired[1]),
|
||||
str(var.num_pending[0]), str(var.num_pending[1]),
|
||||
str(var.num_paid[0]), str(var.num_paid[1]),
|
||||
str(var.num_total[0]), str(var.num_total[1]),
|
||||
str(var.num_canceled[0]), localize(var.num_canceled[1]),
|
||||
str(var.num_refunded[0]), localize(var.num_refunded[1]),
|
||||
str(var.num_expired[0]), localize(var.num_expired[1]),
|
||||
str(var.num_pending[0]), localize(var.num_pending[1]),
|
||||
str(var.num_paid[0]), localize(var.num_paid[1]),
|
||||
str(var.num_total[0]), localize(var.num_total[1]),
|
||||
])
|
||||
|
||||
tdata.append([
|
||||
_("Total"),
|
||||
str(total['num_canceled'][0]), str(total['num_canceled'][1]),
|
||||
str(total['num_refunded'][0]), str(total['num_refunded'][1]),
|
||||
str(total['num_expired'][0]), str(total['num_expired'][1]),
|
||||
str(total['num_pending'][0]), str(total['num_pending'][1]),
|
||||
str(total['num_paid'][0]), str(total['num_paid'][1]),
|
||||
str(total['num_total'][0]), str(total['num_total'][1]),
|
||||
str(total['num_canceled'][0]), localize(total['num_canceled'][1]),
|
||||
str(total['num_refunded'][0]), localize(total['num_refunded'][1]),
|
||||
str(total['num_expired'][0]), localize(total['num_expired'][1]),
|
||||
str(total['num_pending'][0]), localize(total['num_pending'][1]),
|
||||
str(total['num_paid'][0]), localize(total['num_paid'][1]),
|
||||
str(total['num_total'][0]), localize(total['num_total'][1]),
|
||||
])
|
||||
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=3)
|
||||
|
||||
@@ -31,6 +31,10 @@ class SenderView(EventPermissionRequiredMixin, FormView):
|
||||
kwargs['event'] = self.request.event
|
||||
return kwargs
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('We could not send the email. See below for details.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
qs = Order.objects.filter(event=self.request.event)
|
||||
statusq = Q(status__in=form.cleaned_data['sendto'])
|
||||
@@ -94,7 +98,7 @@ class SenderView(EventPermissionRequiredMixin, FormView):
|
||||
if failures:
|
||||
messages.error(self.request, _('Failed to send mails to the following users: {}'.format(' '.join(failures))))
|
||||
else:
|
||||
messages.success(self.request, _('Your message has been queued to be sent to the selected users.'))
|
||||
messages.success(self.request, _('Your message has been queued and will be sent to the selected users.'))
|
||||
|
||||
return redirect(
|
||||
'plugins:sendmail:send',
|
||||
|
||||
@@ -4,7 +4,7 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from .signals import footer_link, html_head
|
||||
from .signals import footer_link, html_footer, html_head
|
||||
|
||||
|
||||
def contextprocessor(request):
|
||||
@@ -19,6 +19,7 @@ def contextprocessor(request):
|
||||
'DEBUG': settings.DEBUG,
|
||||
}
|
||||
_html_head = []
|
||||
_html_foot = []
|
||||
_footer = []
|
||||
|
||||
if hasattr(request, 'event'):
|
||||
@@ -40,6 +41,8 @@ def contextprocessor(request):
|
||||
if hasattr(request, 'event'):
|
||||
for receiver, response in html_head.send(request.event, request=request):
|
||||
_html_head.append(response)
|
||||
for receiver, response in html_footer.send(request.event, request=request):
|
||||
_html_foot.append(response)
|
||||
for receiver, response in footer_link.send(request.event, request=request):
|
||||
if isinstance(response, list):
|
||||
_footer += response
|
||||
@@ -52,6 +55,7 @@ def contextprocessor(request):
|
||||
ctx['event'] = request.event
|
||||
|
||||
ctx['html_head'] = "".join(_html_head)
|
||||
ctx['html_foot'] = "".join(_html_foot)
|
||||
ctx['footer'] = _footer
|
||||
ctx['site_url'] = settings.SITE_URL
|
||||
|
||||
|
||||
@@ -11,6 +11,17 @@ of every page in the frontend. You will get the request as the keyword argument
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
html_footer = EventPluginSignal(
|
||||
providing_args=["request"]
|
||||
)
|
||||
"""
|
||||
This signal allows you to put code before the end of the HTML ``<body>`` tag
|
||||
of every page in the frontend. You will get the request as the keyword argument
|
||||
``request`` and are expected to return plain HTML.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
footer_link = EventPluginSignal(
|
||||
providing_args=["request"]
|
||||
)
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
|
||||
{% endcompress %}
|
||||
<meta name="referrer" content="origin">
|
||||
{{ html_head|safe }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="origin">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
</head>
|
||||
<body data-locale="{{ request.LANGUAGE_CODE }}">
|
||||
@@ -68,5 +68,6 @@
|
||||
{% else %}
|
||||
<script src="{% statici18n LANGUAGE_CODE %}" async></script>
|
||||
{% endif %}
|
||||
{{ html_foot|safe }}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% if avail == 0 %}
|
||||
{% if avail <= 10 %}
|
||||
<div class="col-md-2 col-xs-6 availability-box gone">
|
||||
<strong>{% trans "SOLD OUT" %}</strong>
|
||||
{% if event.settings.waiting_list_enabled %}
|
||||
|
||||
@@ -68,9 +68,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if frontpage_text %}
|
||||
{{ frontpage_text|rich_text }}
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if frontpage_text %}
|
||||
<div>
|
||||
{{ frontpage_text|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-right">
|
||||
<a href="{% eventurl event "presale:event.ical.download" %}" class="btn btn-link btn-xs">
|
||||
<i class="fa fa-calendar"></i> {% trans "Add to Calendar" %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% eventsignal event "pretix.presale.signals.front_page_top" %}
|
||||
{% if event.presale_is_running or event.settings.show_items_outside_presale_period %}
|
||||
<form method="post" data-asynctask
|
||||
@@ -153,11 +163,24 @@
|
||||
{% endblocktrans %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if var.cached_availability.0 == 100 %}
|
||||
{% if item.require_voucher %}
|
||||
<div class="col-md-2 col-xs-6 availability-box unavailable">
|
||||
<small>
|
||||
{% trans "Enter a voucher code below to buy this ticket." %}
|
||||
</small>
|
||||
</div>
|
||||
{% elif var.cached_availability.0 == 100 %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ var.order_max }}"
|
||||
name="variation_{{ item.id }}_{{ var.id }}">
|
||||
{% if item.max_per_order == 1 %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
name="variation_{{ item.id }}_{{ var.id }}">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ var.order_max }}"
|
||||
name="variation_{{ item.id }}_{{ var.id }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 event=event item=item var=var %}
|
||||
@@ -215,8 +238,15 @@
|
||||
</div>
|
||||
{% elif item.cached_availability.0 == 100 %}
|
||||
<div class="col-md-2 col-xs-6 availability-box available">
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ item.order_max }}" name="item_{{ item.id }}">
|
||||
{% if item.max_per_order == 1 %}
|
||||
<label class="item-checkbox-label">
|
||||
<input type="checkbox" value="1"
|
||||
name="item_{{ item.id }}">
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
|
||||
max="{{ item.order_max }}" name="item_{{ item.id }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 event=event item=item var=0 %}
|
||||
|
||||
@@ -56,6 +56,9 @@ event_patterns = [
|
||||
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/invoice/(?P<invoice>[0-9]+)$',
|
||||
pretix.presale.views.order.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
url(r'^ical$',
|
||||
pretix.presale.views.event.EventIcalDownload.as_view(),
|
||||
name='event.ical.download'),
|
||||
url(r'^auth/$', pretix.presale.views.event.EventAuth.as_view(), name='event.auth'),
|
||||
url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib import messages
|
||||
from django.db.models import Count, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import translation
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.generic import TemplateView, View
|
||||
@@ -116,7 +117,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
items = self._items_from_post_data()
|
||||
if items:
|
||||
return self.do(self.request.event.id, items, self.request.session.session_key)
|
||||
return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language())
|
||||
else:
|
||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||
return JsonResponse({
|
||||
@@ -136,7 +137,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
items = self._items_from_post_data()
|
||||
if items:
|
||||
return self.do(self.request.event.id, items, self.request.session.session_key)
|
||||
return self.do(self.request.event.id, items, self.request.session.session_key, translation.get_language())
|
||||
else:
|
||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||
return JsonResponse({
|
||||
@@ -183,6 +184,8 @@ class RedeemView(EventViewMixin, TemplateView):
|
||||
if self.voucher.item_id and self.voucher.variation_id:
|
||||
item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id]
|
||||
|
||||
item.order_max = item.max_per_order or int(self.request.event.settings.max_items_per_order)
|
||||
|
||||
item.has_variations = item.variations.exists()
|
||||
if not item.has_variations:
|
||||
if self.voucher.allow_ignore_quota or self.voucher.block_quota:
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
|
||||
import pytz
|
||||
import vobject
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import TemplateView
|
||||
from pytz import timezone
|
||||
|
||||
from pretix.base.models import ItemVariation
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
@@ -57,11 +63,12 @@ def get_grouped_items(event):
|
||||
display_add_to_cart = False
|
||||
quota_cache = {}
|
||||
for item in items:
|
||||
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
|
||||
if not item.has_variations:
|
||||
item.cached_availability = list(item.check_quotas(_cache=quota_cache))
|
||||
item.order_max = min(item.cached_availability[1]
|
||||
if item.cached_availability[1] is not None else sys.maxsize,
|
||||
int(event.settings.max_items_per_order))
|
||||
max_per_order)
|
||||
item.price = item.default_price
|
||||
item.display_price = item.default_price_net if event.settings.display_net_prices else item.price
|
||||
display_add_to_cart = display_add_to_cart or item.order_max > 0
|
||||
@@ -70,7 +77,7 @@ def get_grouped_items(event):
|
||||
var.cached_availability = list(var.check_quotas(_cache=quota_cache))
|
||||
var.order_max = min(var.cached_availability[1]
|
||||
if var.cached_availability[1] is not None else sys.maxsize,
|
||||
int(event.settings.max_items_per_order))
|
||||
max_per_order)
|
||||
var.display_price = var.net_price if event.settings.display_net_prices else var.price
|
||||
display_add_to_cart = display_add_to_cart or var.order_max > 0
|
||||
if len(item.available_variations) > 0:
|
||||
@@ -105,6 +112,48 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class EventIcalDownload(EventViewMixin, View):
|
||||
|
||||
@cached_property
|
||||
def event_timezone(self):
|
||||
return timezone(self.request.event.settings.timezone)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not self.request.event:
|
||||
raise Http404(_('Unknown event code or not authorized to access this event.'))
|
||||
|
||||
event = self.request.event
|
||||
creation_time = datetime.now(pytz.utc)
|
||||
cal = vobject.iCalendar()
|
||||
cal.add('prodid').value = '-//pretix//{}//'.format(settings.PRETIX_INSTANCE_NAME)
|
||||
|
||||
vevent = cal.add('vevent')
|
||||
vevent.add('summary').value = str(event.name)
|
||||
vevent.add('dtstamp').value = creation_time
|
||||
vevent.add('location').value = str(event.location)
|
||||
vevent.add('organizer').value = event.organizer.name
|
||||
vevent.add('uid').value = '{}-{}-{}'.format(
|
||||
event.organizer.slug, event.slug, creation_time.strftime('%Y%m%d%H%M%S%f')
|
||||
)
|
||||
|
||||
if event.settings.show_times:
|
||||
vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone)
|
||||
else:
|
||||
vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone).date()
|
||||
|
||||
if event.settings.show_date_to:
|
||||
if event.settings.show_times:
|
||||
vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone)
|
||||
else:
|
||||
vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone).date()
|
||||
|
||||
resp = HttpResponse(cal.serialize(), content_type='text/calendar')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}.ics"'.format(
|
||||
event.organizer.slug, event.slug
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
class EventAuth(View):
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -550,11 +550,13 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
extension='', type='', file=None)
|
||||
generate_order.apply_async(args=(self.order.id, self.output.identifier))
|
||||
|
||||
if not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=5):
|
||||
generate_order.apply_async(args=(self.order.id, self.output.identifier))
|
||||
|
||||
if 'ajax' in self.request.GET:
|
||||
return HttpResponse('1' if ct and ct.file else '0')
|
||||
elif not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=110):
|
||||
generate_order.apply_async(args=(self.order.id, self.output.identifier))
|
||||
return render(self.request, "pretixbase/cachedfiles/pending.html", {})
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
@@ -577,11 +579,13 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
extension='', type='', file=None)
|
||||
generate.apply_async(args=(self.order_position.id, self.output.identifier))
|
||||
|
||||
if not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=5):
|
||||
generate.apply_async(args=(self.order_position.id, self.output.identifier))
|
||||
|
||||
if 'ajax' in self.request.GET:
|
||||
return HttpResponse('1' if ct and ct.file else '0')
|
||||
elif not ct.file:
|
||||
if now() - ct.created > timedelta(minutes=110):
|
||||
generate.apply_async(args=(self.order_position.id, self.output.identifier))
|
||||
return render(self.request, "pretixbase/cachedfiles/pending.html", {})
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
|
||||
@@ -50,6 +50,14 @@ debug_fallback = "runserver" in sys.argv
|
||||
DEBUG = config.getboolean('django', 'debug', fallback=debug_fallback)
|
||||
|
||||
db_backend = config.get('database', 'backend', fallback='sqlite3')
|
||||
DATABASE_IS_GALERA = config.getboolean('database', 'galera', fallback=False)
|
||||
if DATABASE_IS_GALERA and 'mysql' in db_backend:
|
||||
db_options = {
|
||||
'init_command': 'SET SESSION wsrep_sync_wait = 1;'
|
||||
}
|
||||
else:
|
||||
db_options = {}
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.' + db_backend,
|
||||
@@ -58,10 +66,10 @@ DATABASES = {
|
||||
'PASSWORD': config.get('database', 'password', fallback=''),
|
||||
'HOST': config.get('database', 'host', fallback=''),
|
||||
'PORT': config.get('database', 'port', fallback=''),
|
||||
'CONN_MAX_AGE': 0 if db_backend == 'sqlite3' else 120
|
||||
'CONN_MAX_AGE': 0 if db_backend == 'sqlite3' else 120,
|
||||
'OPTIONS': db_options
|
||||
}
|
||||
}
|
||||
DATABASE_IS_GALERA = config.getboolean('database', 'galera', fallback=False)
|
||||
|
||||
STATIC_URL = config.get('urls', 'static', fallback='/static/')
|
||||
|
||||
@@ -230,9 +238,9 @@ CORE_MODULES = {
|
||||
}
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'pretix.multidomain.middlewares.MultiDomainMiddleware',
|
||||
'pretix.multidomain.middlewares.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'pretix.multidomain.middlewares.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
|
||||
@@ -240,8 +248,8 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'pretix.control.middleware.PermissionMiddleware',
|
||||
'pretix.base.middleware.LocaleMiddleware',
|
||||
'pretix.presale.middleware.EventMiddleware',
|
||||
'pretix.base.middleware.SecurityMiddleware',
|
||||
'pretix.presale.middleware.EventMiddleware',
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -253,6 +261,11 @@ except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
if METRICS_ENABLED:
|
||||
MIDDLEWARE.insert(MIDDLEWARE.index('pretix.multidomain.middlewares.MultiDomainMiddleware') + 1,
|
||||
'pretix.helpers.metrics.middleware.MetricsMiddleware')
|
||||
|
||||
|
||||
PROFILING_RATE = config.getfloat('django', 'profile', fallback=0) # Percentage of requests to profile
|
||||
if PROFILING_RATE > 0:
|
||||
if not os.path.exists(PROFILE_DIR):
|
||||
|
||||
@@ -92,8 +92,8 @@ div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], d
|
||||
|
||||
.i18n-form-group input,
|
||||
.i18n-form-group textarea {
|
||||
@include border-top-radius(0 px);
|
||||
@include border-bottom-radius(0 px);
|
||||
@include border-top-radius(0px);
|
||||
@include border-bottom-radius(0px);
|
||||
border-top-width: 0;
|
||||
|
||||
&:first-child {
|
||||
|
||||
@@ -27,6 +27,10 @@ nav.navbar {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active {
|
||||
background: $brand-danger !important;
|
||||
}
|
||||
|
||||
.navbar-header .navbar-events {
|
||||
color: white;
|
||||
padding-top: 6px;
|
||||
@@ -207,4 +211,13 @@ body.loading #wrapper {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn .fa {
|
||||
@extend .fa-fw;
|
||||
}
|
||||
|
||||
.action-col-2 {
|
||||
min-width: 95px;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
color: $alert-warning-text;
|
||||
}
|
||||
}
|
||||
.item-checkbox-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.productpicture {
|
||||
float: left;
|
||||
|
||||
@@ -13,3 +13,4 @@ isort
|
||||
pytest-mock
|
||||
pytest-rerunfailures
|
||||
pytest-warnings
|
||||
responses
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Functional requirements
|
||||
Django==1.10.*
|
||||
Django>=1.10.7,<1.11
|
||||
python-dateutil
|
||||
pytz
|
||||
django-bootstrap3==8.0.*
|
||||
@@ -18,7 +18,6 @@ kombu==4.0.2
|
||||
django-statici18n==1.3.*
|
||||
inlinestyler==0.2.*
|
||||
BeautifulSoup4
|
||||
html5lib<0.99999999,>=0.999 # version requirement by bleach
|
||||
slimit
|
||||
lxml
|
||||
static3==0.6.1
|
||||
@@ -26,7 +25,7 @@ dj-static
|
||||
csscompressor
|
||||
django-markup
|
||||
markdown
|
||||
bleach==1.5
|
||||
bleach==2.*
|
||||
raven
|
||||
django-i18nfield
|
||||
# Stripe
|
||||
@@ -37,3 +36,4 @@ pycparser==2.13 # https://github.com/eliben/pycparser/issues/147
|
||||
# Banktransfer
|
||||
chardet>=2.3,<3
|
||||
mt-940==3.2
|
||||
vobject==0.9.*
|
||||
|
||||
@@ -81,7 +81,6 @@ setup(
|
||||
'django-statici18n==1.3.*',
|
||||
'inlinestyler==0.2.*',
|
||||
'BeautifulSoup4',
|
||||
'html5lib<0.99999999,>=0.999',
|
||||
'slimit',
|
||||
'lxml',
|
||||
'static3==0.6.1',
|
||||
@@ -89,7 +88,7 @@ setup(
|
||||
'csscompressor',
|
||||
'django-markup',
|
||||
'markdown',
|
||||
'bleach==1.5',
|
||||
'bleach==2.*',
|
||||
'raven',
|
||||
'paypalrestsdk==1.12.*',
|
||||
'pycparser==2.13',
|
||||
@@ -98,7 +97,8 @@ setup(
|
||||
'stripe==1.22.*',
|
||||
'chardet>=2.3,<3',
|
||||
'mt-940==3.2',
|
||||
'django-i18nfield'
|
||||
'django-i18nfield',
|
||||
'vobject==0.9.*'
|
||||
],
|
||||
extras_require={
|
||||
'dev': [
|
||||
@@ -115,7 +115,8 @@ setup(
|
||||
'isort',
|
||||
'pytest-mock',
|
||||
'pytest-rerunfailures',
|
||||
'pytest-warnings'
|
||||
'pytest-warnings',
|
||||
'responses'
|
||||
],
|
||||
'memcached': ['pylibmc'],
|
||||
'mysql': ['mysqlclient'],
|
||||
|
||||
@@ -41,12 +41,14 @@ def env():
|
||||
item=ticket,
|
||||
variation=None,
|
||||
price=Decimal("23.00"),
|
||||
positionid=1,
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o,
|
||||
item=t_shirt,
|
||||
variation=variation,
|
||||
price=Decimal("42.00"),
|
||||
positionid=2,
|
||||
)
|
||||
return event, o
|
||||
|
||||
|
||||
@@ -14,19 +14,25 @@ class FakeRedis(object):
|
||||
def __init__(self):
|
||||
self.storage = {}
|
||||
|
||||
def incrbyfloat(self, rkey, amount):
|
||||
def hincrbyfloat(self, k, rkey, amount):
|
||||
if rkey in self.storage:
|
||||
self.storage[rkey] += amount
|
||||
else:
|
||||
self.set(rkey, amount)
|
||||
self.hset(k, rkey, amount)
|
||||
|
||||
def set(self, rkey, value):
|
||||
def hset(self, k, rkey, value):
|
||||
self.storage[rkey] = value
|
||||
|
||||
def get(self, rkey):
|
||||
def hget(self, k, rkey):
|
||||
# bytes-conversion here for emulating redis behavior without making incr too hard
|
||||
return bytes(self.storage[rkey], encoding='utf-8')
|
||||
|
||||
def pipeline(self):
|
||||
return self
|
||||
|
||||
def execute(self):
|
||||
pass
|
||||
|
||||
|
||||
@override_settings(HAS_REDIS=True)
|
||||
def test_counter(monkeypatch):
|
||||
@@ -34,35 +40,37 @@ def test_counter(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
|
||||
monkeypatch.setattr(metrics, "redis", fake_redis, raising=False)
|
||||
pretix_view_requests_total = metrics.Counter("pretix_view_requests_total", "Total number of HTTP requests made.",
|
||||
["status_code", "method", "url_name"])
|
||||
|
||||
# now test
|
||||
fullname_get = metrics.http_requests_total._construct_metric_identifier(
|
||||
'http_requests_total', {"code": "200", "handler": "/foo", "method": "GET"}
|
||||
fullname_get = pretix_view_requests_total._construct_metric_identifier(
|
||||
'pretix_view_requests_total', {"status_code": "200", "url_name": "foo", "method": "GET"}
|
||||
)
|
||||
fullname_post = metrics.http_requests_total._construct_metric_identifier(
|
||||
'http_requests_total', {"code": "200", "handler": "/foo", "method": "POST"}
|
||||
fullname_post = pretix_view_requests_total._construct_metric_identifier(
|
||||
'pretix_view_requests_total', {"status_code": "200", "url_name": "foo", "method": "POST"}
|
||||
)
|
||||
metrics.http_requests_total.inc(code="200", handler="/foo", method="GET")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_get] == 1
|
||||
metrics.http_requests_total.inc(code="200", handler="/foo", method="GET")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_get] == 2
|
||||
metrics.http_requests_total.inc(7, code="200", handler="/foo", method="GET")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_get] == 9
|
||||
metrics.http_requests_total.inc(7, code="200", handler="/foo", method="POST")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_get] == 9
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_post] == 7
|
||||
pretix_view_requests_total.inc(status_code="200", url_name="foo", method="GET")
|
||||
assert fake_redis.storage[fullname_get] == 1
|
||||
pretix_view_requests_total.inc(status_code="200", url_name="foo", method="GET")
|
||||
assert fake_redis.storage[fullname_get] == 2
|
||||
pretix_view_requests_total.inc(7, status_code="200", url_name="foo", method="GET")
|
||||
assert fake_redis.storage[fullname_get] == 9
|
||||
pretix_view_requests_total.inc(7, status_code="200", url_name="foo", method="POST")
|
||||
assert fake_redis.storage[fullname_get] == 9
|
||||
assert fake_redis.storage[fullname_post] == 7
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
metrics.http_requests_total.inc(-4, code="200", handler="/foo", method="POST")
|
||||
pretix_view_requests_total.inc(-4, status_code="200", url_name="foo", method="POST")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
metrics.http_requests_total.inc(-4, code="200", handler="/foo", method="POST", too="much")
|
||||
pretix_view_requests_total.inc(-4, status_code="200", url_name="foo", method="POST", too="much")
|
||||
|
||||
# test dimensionless counters
|
||||
dimless_counter = metrics.Counter("dimless_counter", "this is a helpstring")
|
||||
fullname_dimless = dimless_counter._construct_metric_identifier('dimless_counter')
|
||||
dimless_counter.inc(20)
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_dimless] == 20
|
||||
assert fake_redis.storage[fullname_dimless] == 20
|
||||
|
||||
|
||||
@override_settings(HAS_REDIS=True)
|
||||
@@ -80,29 +88,29 @@ def test_gauge(monkeypatch):
|
||||
fullname_three = test_gauge._construct_metric_identifier('my_gauge', {"dimension": "three"})
|
||||
|
||||
test_gauge.inc(dimension="one")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 1
|
||||
assert fake_redis.storage[fullname_one] == 1
|
||||
test_gauge.inc(7, dimension="one")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 8
|
||||
assert fake_redis.storage[fullname_one] == 8
|
||||
test_gauge.dec(2, dimension="one")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
test_gauge.set(3, dimension="two")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 3
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_two] == 3
|
||||
test_gauge.set(4, dimension="two")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_two] == 4
|
||||
test_gauge.dec(7, dimension="three")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == -7
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_two] == 4
|
||||
assert fake_redis.storage[fullname_three] == -7
|
||||
test_gauge.inc(14, dimension="three")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == 7
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_two] == 4
|
||||
assert fake_redis.storage[fullname_three] == 7
|
||||
test_gauge.set(17, dimension="three")
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_one] == 6
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_two] == 4
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_three] == 17
|
||||
assert fake_redis.storage[fullname_one] == 6
|
||||
assert fake_redis.storage[fullname_two] == 4
|
||||
assert fake_redis.storage[fullname_three] == 17
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
test_gauge.inc(-17, dimension="three")
|
||||
@@ -120,7 +128,42 @@ def test_gauge(monkeypatch):
|
||||
dimless_gauge = metrics.Gauge("dimless_gauge", "this is a helpstring")
|
||||
fullname_dimless = dimless_gauge._construct_metric_identifier('dimless_gauge')
|
||||
dimless_gauge.set(20)
|
||||
assert fake_redis.storage[metrics.REDIS_KEY_PREFIX + fullname_dimless] == 20
|
||||
assert fake_redis.storage[fullname_dimless] == 20
|
||||
|
||||
|
||||
@override_settings(HAS_REDIS=True)
|
||||
def test_histogram(monkeypatch):
|
||||
|
||||
fake_redis = FakeRedis()
|
||||
|
||||
monkeypatch.setattr(metrics, "redis", fake_redis, raising=False)
|
||||
|
||||
test_hist = metrics.Histogram("my_histogram", "this is a helpstring", ["dimension"])
|
||||
|
||||
# now test
|
||||
test_hist.observe(3.0, dimension="one")
|
||||
assert fake_redis.storage['my_histogram_count{dimension="one"}'] == 1
|
||||
assert fake_redis.storage['my_histogram_sum{dimension="one"}'] == 3.0
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="5.0"}'] == 1
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="10.0"}'] == 1
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="+Inf"}'] == 1
|
||||
test_hist.observe(3.0, dimension="one")
|
||||
assert fake_redis.storage['my_histogram_count{dimension="one"}'] == 2
|
||||
assert fake_redis.storage['my_histogram_sum{dimension="one"}'] == 6.0
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="5.0"}'] == 2
|
||||
test_hist.observe(0.9, dimension="one")
|
||||
assert fake_redis.storage['my_histogram_count{dimension="one"}'] == 3
|
||||
assert fake_redis.storage['my_histogram_sum{dimension="one"}'] == 6.9
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="5.0"}'] == 3
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="1.0"}'] == 1
|
||||
test_hist.observe(0.9, dimension="two")
|
||||
assert fake_redis.storage['my_histogram_count{dimension="one"}'] == 3
|
||||
assert fake_redis.storage['my_histogram_count{dimension="two"}'] == 1
|
||||
assert fake_redis.storage['my_histogram_sum{dimension="one"}'] == 6.9
|
||||
assert fake_redis.storage['my_histogram_sum{dimension="two"}'] == 0.9
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="5.0"}'] == 3
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="one",le="1.0"}'] == 1
|
||||
assert fake_redis.storage['my_histogram_bucket{dimension="two",le="1.0"}'] == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -131,8 +174,13 @@ def test_metrics_view(monkeypatch, client):
|
||||
monkeypatch.setattr(metricsview.metrics, "redis", fake_redis, raising=False)
|
||||
|
||||
counter_value = 3
|
||||
fullname = metrics.http_requests_total._construct_metric_identifier('http_requests_total', {"code": "200", "handler": "/foo", "method": "GET"})
|
||||
metricsview.metrics.http_requests_total.inc(counter_value, code="200", handler="/foo", method="GET")
|
||||
pretix_view_requests_total = metrics.Counter("pretix_view_requests_total", "Total number of HTTP requests made.",
|
||||
["status_code", "method", "url_name"])
|
||||
fullname = pretix_view_requests_total._construct_metric_identifier(
|
||||
'http_requests_total',
|
||||
{"status_code": "200", "url_name": "foo", "method": "GET"}
|
||||
)
|
||||
pretix_view_requests_total.inc(counter_value, status_code="200", url_name="foo", method="GET")
|
||||
|
||||
# test unauthorized-page
|
||||
assert "You are not authorized" in client.get('/metrics').content.decode('utf-8')
|
||||
|
||||
@@ -199,7 +199,7 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
|
||||
v.block_quota = True
|
||||
v.save()
|
||||
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
|
||||
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
def test_voucher_variation(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
@@ -212,7 +212,7 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
|
||||
v.block_quota = True
|
||||
v.save()
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
def test_voucher_quota(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
@@ -225,7 +225,7 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
|
||||
v.block_quota = True
|
||||
v.save()
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
def test_voucher_quota_multiuse(self):
|
||||
self.quota.size = 5
|
||||
@@ -234,7 +234,7 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, max_usages=5, redeemed=2)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 2))
|
||||
Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, max_usages=2)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
def test_voucher_multiuse_count_overredeemed(self):
|
||||
if 'sqlite' not in settings.DATABASES['default']['ENGINE']:
|
||||
@@ -266,7 +266,7 @@ class QuotaTestCase(BaseQuotaTestCase):
|
||||
self.quota.save()
|
||||
Voucher.objects.create(quota=self.quota, event=self.event, valid_until=now() + timedelta(days=5),
|
||||
block_quota=True)
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0))
|
||||
self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0))
|
||||
|
||||
def test_voucher_quota_expired(self):
|
||||
self.quota.variations.add(self.var1)
|
||||
|
||||
189
src/tests/base/test_updatecheck.py
Normal file
189
src/tests/base/test_updatecheck.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from django.core import mail as djmail
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix import __version__
|
||||
from pretix.base.services import update_check
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
|
||||
def request_callback_updatable(request):
|
||||
json_data = json.loads(request.body.decode())
|
||||
resp_body = {
|
||||
'status': 'ok',
|
||||
'version': {
|
||||
'latest': '1000.0.0',
|
||||
'yours': json_data.get('version'),
|
||||
'updatable': True
|
||||
},
|
||||
'plugins': {}
|
||||
}
|
||||
return 200, {'Content-Type': 'text/json'}, json.dumps(resp_body)
|
||||
|
||||
|
||||
def request_callback_not_updatable(request):
|
||||
json_data = json.loads(request.body.decode())
|
||||
resp_body = {
|
||||
'status': 'ok',
|
||||
'version': {
|
||||
'latest': '1.0.0',
|
||||
'yours': json_data.get('version'),
|
||||
'updatable': False
|
||||
},
|
||||
'plugins': {}
|
||||
}
|
||||
return 200, {'Content-Type': 'text/json'}, json.dumps(resp_body)
|
||||
|
||||
|
||||
def request_callback_disallowed(request):
|
||||
pytest.fail("Request issued even though none should be issued.")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_update_check_disabled():
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings.update_check_perform = False
|
||||
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_disallowed,
|
||||
content_type='application/json',
|
||||
)
|
||||
update_check.update_check.apply(throw=True)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_update_check_sent_no_updates():
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_not_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
update_check.update_check.apply(throw=True)
|
||||
gs = GlobalSettingsObject()
|
||||
assert not gs.settings.update_check_result_warning
|
||||
storeddata = gs.settings.update_check_result
|
||||
assert not storeddata['version']['updatable']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_update_check_sent_updates():
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
update_check.update_check.apply(throw=True)
|
||||
gs = GlobalSettingsObject()
|
||||
assert gs.settings.update_check_result_warning
|
||||
storeddata = gs.settings.update_check_result
|
||||
assert storeddata['version']['updatable']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_update_check_mail_sent():
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings.update_check_email = 'test@example.org'
|
||||
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
update_check.update_check.apply(throw=True)
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == ['test@example.org']
|
||||
assert 'update' in djmail.outbox[0].subject
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_update_check_mail_sent_only_after_change():
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings.update_check_email = 'test@example.org'
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
update_check.update_check.apply(throw=True)
|
||||
assert len(djmail.outbox) == 1
|
||||
|
||||
update_check.update_check.apply(throw=True)
|
||||
assert len(djmail.outbox) == 1
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_not_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
update_check.update_check.apply(throw=True)
|
||||
assert len(djmail.outbox) == 1
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
update_check.update_check.apply(throw=True)
|
||||
assert len(djmail.outbox) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_cron_interval(monkeypatch):
|
||||
called = False
|
||||
|
||||
def callee():
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
monkeypatch.setattr(update_check.update_check, 'apply_async', callee)
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings.update_check_email = 'test@example.org'
|
||||
|
||||
gs.settings.update_check_last = now() - timedelta(hours=14)
|
||||
update_check.run_update_check(None)
|
||||
assert not called
|
||||
|
||||
gs.settings.update_check_last = now() - timedelta(hours=24)
|
||||
update_check.run_update_check(None)
|
||||
assert called
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_result_table_empty():
|
||||
assert update_check.check_result_table() == {
|
||||
'error': 'no_result'
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.django_db
|
||||
def test_result_table_up2date():
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://pretix.eu/.update_check/',
|
||||
callback=request_callback_not_updatable,
|
||||
content_type='application/json',
|
||||
)
|
||||
update_check.update_check.apply(throw=True)
|
||||
tbl = update_check.check_result_table()
|
||||
assert tbl[0] == ('pretix', __version__, '1.0.0', False)
|
||||
assert tbl[1][0].startswith('Plugin: ')
|
||||
assert tbl[1][2] == '?'
|
||||
58
src/tests/control/test_updatecheck.py
Normal file
58
src/tests/control/test_updatecheck.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_notice_displayed(client, user):
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
r = client.get('/control/')
|
||||
assert 'pretix automatically checks for updates in the background' not in r.content.decode()
|
||||
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
r = client.get('/control/')
|
||||
assert 'pretix automatically checks for updates in the background' in r.content.decode()
|
||||
|
||||
client.get('/control/global/update/') # Click it
|
||||
r = client.get('/control/')
|
||||
assert 'pretix automatically checks for updates in the background' not in r.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_settings(client, user):
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
client.post('/control/global/update/', {'update_check_email': 'test@example.org', 'update_check_perform': 'on'})
|
||||
gs = GlobalSettingsObject()
|
||||
gs.settings._flush()
|
||||
assert gs.settings.update_check_perform
|
||||
assert gs.settings.update_check_email
|
||||
|
||||
client.post('/control/global/update/', {'update_check_email': '', 'update_check_perform': ''})
|
||||
gs.settings._flush()
|
||||
assert not gs.settings.update_check_perform
|
||||
assert not gs.settings.update_check_email
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_trigger(client, user):
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
assert not gs.settings.update_check_last
|
||||
client.post('/control/global/update/', {'trigger': 'on'})
|
||||
gs.settings._flush()
|
||||
assert gs.settings.update_check_last
|
||||
@@ -133,6 +133,18 @@ class VoucherFormTest(SoupTest):
|
||||
assert len(codes) == 7
|
||||
assert all([len(r) == 16 for r in codes])
|
||||
|
||||
def test_display_voucher_code(self):
|
||||
count_before = self.event.vouchers.count()
|
||||
doc = self.get_doc('/control/event/%s/%s/vouchers/add' % (self.orga.slug, self.event.slug))
|
||||
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
|
||||
form_data.update({
|
||||
'itemvar': '%d' % self.ticket.pk
|
||||
})
|
||||
doc = self.post_doc('/control/event/%s/%s/vouchers/add' % (self.orga.slug, self.event.slug), form_data)
|
||||
v = Voucher.objects.latest('pk')
|
||||
assert v.code in doc.select(".alert-success")[0].text
|
||||
assert count_before + 1 == self.event.vouchers.count()
|
||||
|
||||
def test_create_non_blocking_item_voucher(self):
|
||||
self._create_voucher({
|
||||
'itemvar': '%d' % self.ticket.pk
|
||||
|
||||
177
src/tests/plugins/test_sendmail.py
Normal file
177
src/tests/plugins/test_sendmail.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.core import mail as djmail
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import (
|
||||
Event, EventPermission, Item, ItemCategory, Order, OrderPosition,
|
||||
Organizer, OrganizerPermission, User,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event():
|
||||
"""Returns an event instance"""
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now(),
|
||||
plugins='pretix.plugins.sendmail,tests.testdummy',
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item(event):
|
||||
"""Returns an item instance"""
|
||||
return Item.objects.create(name='Test item', event=event, default_price=13)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item_category(event):
|
||||
"""Returns an item category instance"""
|
||||
return ItemCategory.objects.create(event=event)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order(item):
|
||||
"""Returns an order instance"""
|
||||
o = Order.objects.create(event=item.event, status=Order.STATUS_PENDING,
|
||||
expires=now() + datetime.timedelta(hours=1),
|
||||
total=13, code='DUMMY', email='dummy@dummy.test',
|
||||
datetime=now(), payment_provider='banktransfer')
|
||||
OrderPosition.objects.create(order=o, item=item, price=13)
|
||||
return o
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_client(client, event):
|
||||
"""Returns a logged client"""
|
||||
user = User.objects.create_superuser('dummy@dummy.dummy', 'dummy')
|
||||
OrganizerPermission.objects.create(organizer=event.organizer, user=user, can_create_events=True)
|
||||
EventPermission.objects.create(event=event, user=user, can_change_items=True,
|
||||
can_change_settings=True, can_change_orders=True, can_view_orders=True)
|
||||
client.force_login(user)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sendmail_url(event):
|
||||
"""Returns a url for sendmail"""
|
||||
url = '/control/event/{orga}/{event}/sendmail/'.format(
|
||||
event=event.slug, orga=event.organizer.slug,
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_view(logged_in_client, sendmail_url, expected=200):
|
||||
response = logged_in_client.get(sendmail_url)
|
||||
|
||||
assert response.status_code == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_simple_case(logged_in_client, sendmail_url, event, order):
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url,
|
||||
{'sendto': 'n',
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.'
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [order.email]
|
||||
assert djmail.outbox[0].subject == 'Test subject'
|
||||
assert 'This is a test file for sending mails.' in djmail.outbox[0].body
|
||||
|
||||
url = sendmail_url + 'history/'
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'Test subject' in response.rendered_content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_email_not_sent_if_order_not_match(logged_in_client, sendmail_url, event, order):
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url,
|
||||
{'sendto': 'p',
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.'
|
||||
},
|
||||
follow=True)
|
||||
assert 'alert-danger' in response.rendered_content
|
||||
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_preview(logged_in_client, sendmail_url, event, order):
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url,
|
||||
{'sendto': 'n',
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'This is a test file for sending mails.',
|
||||
'action': 'preview'
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'E-mail preview' in response.rendered_content
|
||||
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_invalid_data(logged_in_client, sendmail_url, event, order):
|
||||
djmail.outbox = []
|
||||
response = logged_in_client.post(sendmail_url,
|
||||
{'sendto': 'n',
|
||||
'subject_0': 'Test subject',
|
||||
},
|
||||
follow=True)
|
||||
|
||||
assert 'has-error' in response.rendered_content
|
||||
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sendmail_multi_locales(logged_in_client, sendmail_url, event, item):
|
||||
djmail.outbox = []
|
||||
|
||||
event.settings.set('locales', ['en', 'de'])
|
||||
|
||||
o = Order.objects.create(event=item.event, status=Order.STATUS_PAID,
|
||||
expires=now() + datetime.timedelta(hours=1),
|
||||
total=13, code='DUMMY', email='dummy@dummy.test',
|
||||
datetime=now(), payment_provider='banktransfer',
|
||||
locale='de')
|
||||
OrderPosition.objects.create(order=o, item=item, price=13)
|
||||
|
||||
response = logged_in_client.post(sendmail_url,
|
||||
{'sendto': 'p',
|
||||
'subject_0': 'Test subject',
|
||||
'message_0': 'Test message',
|
||||
'subject_1': 'Benutzer',
|
||||
'message_1': 'Test nachricht'
|
||||
},
|
||||
follow=True)
|
||||
assert response.status_code == 200
|
||||
assert 'alert-success' in response.rendered_content
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [o.email]
|
||||
assert djmail.outbox[0].subject == 'Benutzer'
|
||||
assert 'Test nachricht' in djmail.outbox[0].body
|
||||
|
||||
url = sendmail_url + 'history/'
|
||||
response = logged_in_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'Benutzer' in response.rendered_content
|
||||
assert 'Test nachricht' in response.rendered_content
|
||||
@@ -340,6 +340,36 @@ class CartTest(CartTestMixin, TestCase):
|
||||
self.assertIn('more than', doc.select('.alert-danger')[0].text)
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1)
|
||||
|
||||
def test_max_per_item_failed(self):
|
||||
self.ticket.max_per_order = 2
|
||||
self.ticket.save()
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'item_%d' % self.ticket.id: '2',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
self.assertIn('more than', doc.select('.alert-danger')[0].text)
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1)
|
||||
|
||||
def test_max_per_item_success(self):
|
||||
self.ticket.max_per_order = 3
|
||||
self.ticket.save()
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
|
||||
'item_%d' % self.ticket.id: '2',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 3)
|
||||
|
||||
def test_quota_full(self):
|
||||
self.quota_tickets.size = 0
|
||||
self.quota_tickets.save()
|
||||
|
||||
@@ -553,6 +553,33 @@ class CheckoutTestCase(TestCase):
|
||||
self.assertEqual(Order.objects.count(), 1)
|
||||
self.assertEqual(OrderPosition.objects.count(), 1)
|
||||
|
||||
def test_max_per_item_failed(self):
|
||||
self.quota_tickets.size = 3
|
||||
self.quota_tickets.save()
|
||||
self.ticket.max_per_order = 1
|
||||
self.ticket.save()
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10),
|
||||
)
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10),
|
||||
)
|
||||
self._set_session('payment', 'banktransfer')
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key).count(), 1)
|
||||
self.assertEqual(len(doc.select(".alert-danger")), 1)
|
||||
self.assertFalse(Order.objects.exists())
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
self.assertEqual(Order.objects.count(), 1)
|
||||
self.assertEqual(OrderPosition.objects.count(), 1)
|
||||
|
||||
def test_confirm_expired_partial(self):
|
||||
self.quota_tickets.size = 1
|
||||
self.quota_tickets.save()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import datetime
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import mail
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from pytz import timezone
|
||||
from tests.base import SoupTest
|
||||
|
||||
from pretix.base.models import (
|
||||
@@ -49,6 +52,16 @@ class EventMiddlewareTest(EventTestMixin, SoupTest):
|
||||
resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_not_found_event(self):
|
||||
resp = self.client.get('/%s/%s/ical' % ('foo', 'bar'))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_mandatory_field(self):
|
||||
self.event.date_to = self.event.date_from + datetime.timedelta(days=2)
|
||||
self.event.save()
|
||||
resp = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
class ItemDisplayTest(EventTestMixin, SoupTest):
|
||||
def test_not_active(self):
|
||||
@@ -548,6 +561,115 @@ class TestResendLink(EventTestMixin, SoupTest):
|
||||
self.assertIn('DUMMY2', mail.outbox[0].body)
|
||||
|
||||
|
||||
class EventIcalDownloadTest(EventTestMixin, SoupTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.event.settings.show_date_to = True
|
||||
self.event.settings.show_times = True
|
||||
self.event.location = 'DUMMY ARENA'
|
||||
self.event.date_from = datetime.datetime(2013, 12, 26, 21, 57, 58, tzinfo=datetime.timezone.utc)
|
||||
self.event.date_to = self.event.date_from + datetime.timedelta(days=2)
|
||||
self.event.settings.timezone = 'UTC'
|
||||
self.event.save()
|
||||
|
||||
def test_response_type(self):
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug))
|
||||
self.assertEqual(ical['Content-Type'], 'text/calendar')
|
||||
self.assertEqual(ical['Content-Disposition'], 'attachment; filename="{}-{}.ics"'.format(
|
||||
self.orga.slug, self.event.slug
|
||||
))
|
||||
|
||||
def test_header_footer(self):
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header')
|
||||
self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer')
|
||||
self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header')
|
||||
self.assertIn('END:VEVENT', ical, 'missing VEVENT footer')
|
||||
|
||||
def test_timezone_header_footer(self):
|
||||
self.event.settings.timezone = 'Asia/Tokyo'
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header')
|
||||
self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer')
|
||||
self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header')
|
||||
self.assertIn('END:VEVENT', ical, 'missing VEVENT footer')
|
||||
self.assertIn('BEGIN:VTIMEZONE', ical, 'missing VTIMEZONE header')
|
||||
self.assertIn('END:VTIMEZONE', ical, 'missing VTIMEZONE footer')
|
||||
|
||||
def test_metadata(self):
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertIn('VERSION:2.0', ical, 'incorrect version tag - 2.0')
|
||||
self.assertIn('-//pretix//%s//' % settings.PRETIX_INSTANCE_NAME, ical, 'incorrect PRODID')
|
||||
|
||||
def test_event_info(self):
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertIn('SUMMARY:%s' % self.event.name, ical, 'incorrect correct summary')
|
||||
self.assertIn('LOCATION:DUMMY ARENA', ical, 'incorrect location')
|
||||
self.assertIn('ORGANIZER:%s' % self.event.organizer.name, ical, 'incorrect organizer')
|
||||
self.assertTrue(re.search(r'DTSTAMP:\d{8}T\d{6}Z', ical), 'incorrect timestamp')
|
||||
self.assertTrue(re.search(r'UID:\w*-\w*-\d{20}', ical), 'missing UID key')
|
||||
|
||||
def test_utc_timezone(self):
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
# according to icalendar spec, timezone must NOT be shown if it is UTC
|
||||
self.assertIn('DTSTART:%s' % self.event.date_from.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect start time')
|
||||
self.assertIn('DTEND:%s' % self.event.date_to.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect end time')
|
||||
|
||||
def test_include_timezone(self):
|
||||
self.event.settings.timezone = 'Asia/Tokyo'
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
# according to icalendar spec, timezone must be shown if it is not UTC
|
||||
fmt = '%Y%m%dT%H%M%S'
|
||||
self.assertIn('DTSTART;TZID=%s:%s' %
|
||||
(self.event.settings.timezone,
|
||||
self.event.date_from.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)),
|
||||
ical, 'incorrect start time')
|
||||
self.assertIn('DTEND;TZID=%s:%s' %
|
||||
(self.event.settings.timezone,
|
||||
self.event.date_to.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)),
|
||||
ical, 'incorrect end time')
|
||||
self.assertIn('TZID:%s' % self.event.settings.timezone, ical, 'missing VCALENDAR')
|
||||
|
||||
def test_no_time(self):
|
||||
self.event.settings.show_times = False
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date')
|
||||
self.assertIn('DTEND;VALUE=DATE:%s' % self.event.date_to.strftime('%Y%m%d'), ical, 'incorrect end date')
|
||||
|
||||
def test_no_date_to(self):
|
||||
self.event.settings.timezone = 'Asia/Tokyo'
|
||||
self.event.settings.show_date_to = False
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
fmt = '%Y%m%dT%H%M%S'
|
||||
self.assertIn('DTSTART;TZID=%s:%s' %
|
||||
(self.event.settings.timezone,
|
||||
self.event.date_from.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)),
|
||||
ical, 'incorrect start time')
|
||||
self.assertNotIn('DTEND', ical, 'unexpected end time attribute')
|
||||
|
||||
def test_no_date_to_and_time(self):
|
||||
self.event.settings.show_date_to = False
|
||||
self.event.settings.show_times = False
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date')
|
||||
self.assertNotIn('DTEND', ical, 'unexpected end time attribute')
|
||||
|
||||
def test_local_date_diff_from_utc(self):
|
||||
self.event.date_from = datetime.datetime(2013, 12, 26, 21, 57, 58, tzinfo=datetime.timezone.utc)
|
||||
self.event.date_to = self.event.date_from + datetime.timedelta(days=2)
|
||||
self.event.settings.timezone = 'Asia/Tokyo'
|
||||
self.event.settings.show_times = False
|
||||
self.event.save()
|
||||
ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode()
|
||||
self.assertIn('DTSTART;VALUE=DATE:20131227', ical, 'incorrect start date')
|
||||
self.assertIn('DTEND;VALUE=DATE:20131229', ical, 'incorrect end date')
|
||||
|
||||
|
||||
class EventSlugBlacklistValidatorTest(EventTestMixin, SoupTest):
|
||||
def test_slug_validation(self):
|
||||
event = Event(
|
||||
|
||||
Reference in New Issue
Block a user