forked from CGM_Public/pretix_original
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7509bf69ca | ||
|
|
d9adec88c8 | ||
|
|
938a1bca0d | ||
|
|
ab757c502c | ||
|
|
6b17388bd8 | ||
|
|
48a933b757 | ||
|
|
6c02bf73b5 | ||
|
|
960d0bcdf2 | ||
|
|
d389e4390f | ||
|
|
55ce83a642 | ||
|
|
300f8f666d | ||
|
|
5d6083dce4 | ||
|
|
82f9f5027f | ||
|
|
4f015f1d96 | ||
|
|
bbe272c35c | ||
|
|
39513448f3 | ||
|
|
bee61bf398 | ||
|
|
010c31cf10 | ||
|
|
d1643b4506 | ||
|
|
623307b348 | ||
|
|
09e8fca132 | ||
|
|
2c96a26d91 | ||
|
|
f639d2aa57 | ||
|
|
5a68eb345f | ||
|
|
603a3d78fc | ||
|
|
cafc6a7226 | ||
|
|
0b068f6d79 | ||
|
|
ec73c916b7 | ||
|
|
110ccb5587 | ||
|
|
d224ae3eb0 | ||
|
|
dd9c0b3a01 | ||
|
|
d2d711c1f8 | ||
|
|
3dd2492926 | ||
|
|
bc1520ec35 | ||
|
|
3033a82c92 | ||
|
|
bb75be7e8e | ||
|
|
b52f2f5a9e | ||
|
|
5bcfb958f0 | ||
|
|
5f52963ce0 | ||
|
|
3f76be2287 | ||
|
|
92aa65a839 | ||
|
|
bd5337a2c2 | ||
|
|
990d5815f2 | ||
|
|
c1d51cc196 | ||
|
|
f5b871f8f5 | ||
|
|
bc6b84f900 | ||
|
|
5ee79c8148 | ||
|
|
e4706dd3ba | ||
|
|
3c59a870e7 | ||
|
|
ae6ad8870d | ||
|
|
07fed0acce | ||
|
|
7dd99f3d18 | ||
|
|
03d8cfb401 | ||
|
|
ccb981e6ce | ||
|
|
984d5c716b | ||
|
|
43121a08bd | ||
|
|
54c7f16c4c | ||
|
|
6cd2674f2a | ||
|
|
602947a3d7 | ||
|
|
5048963aa2 | ||
|
|
8d16e2b59b | ||
|
|
4accbef6a9 | ||
|
|
2e9d95b96a | ||
|
|
03dfd1b96f | ||
|
|
ee1ccb7f01 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,4 +21,6 @@ pretixeu/
|
||||
local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ http {
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
access_log /var/log/nginx/access.log private;
|
||||
error_log /var/log/nginx/error.log;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
gzip on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
@@ -246,11 +246,11 @@ To install a plugin, you need to build your own docker image. To do so, create a
|
||||
named ``Dockerfile`` in it. The Dockerfile could look like this (replace ``pretix-passbook`` with the plugins of your
|
||||
choice)::
|
||||
|
||||
FROM pretix/standalone
|
||||
FROM pretix/standalone:stable
|
||||
USER root
|
||||
RUN pip3 install pretix-passbook
|
||||
USER pretixuser
|
||||
RUN make production
|
||||
RUN cd /pretix/src && make production
|
||||
|
||||
Then, go to that directory and build the image::
|
||||
|
||||
|
||||
@@ -213,6 +213,9 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
ssl_certificate /path/to/cert.chain.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
add_header Referrer-Options same-origin;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8345/;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -11,7 +11,7 @@ Core
|
||||
----
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues
|
||||
:members: periodic_task, event_live_issues, event_copy_data
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
@@ -47,7 +47,7 @@ Backend
|
||||
-------
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, organizer_edit_tabs
|
||||
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -18,8 +18,9 @@ If you improved pretix in any way, we'd be very happy if you contribute it
|
||||
back to the main code base! The easiest way to do so is to `create a pull request`_
|
||||
on our `GitHub repository`_.
|
||||
|
||||
Before you do so, please `squash all your changes`_ into one single commit. Please
|
||||
use the test suite to check whether your changes break any existing features and run
|
||||
We recommend that you create a feature branch for every issue you work on so the changes can
|
||||
be reviewed individually.
|
||||
Please use the test suite to check whether your changes break any existing features and run
|
||||
the code style checks to confirm you are consistent with pretix's coding style. You'll
|
||||
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
|
||||
|
||||
@@ -34,4 +35,3 @@ Again: If you get stuck, do not hesitate to contact any of us, or Raphael person
|
||||
|
||||
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
|
||||
.. _GitHub repository: https://github.com/pretix/pretix
|
||||
.. _squash all your changes: https://davidwalsh.name/squash-commits-git
|
||||
|
||||
@@ -2,7 +2,10 @@ Settings storage
|
||||
================
|
||||
|
||||
pretix is highly configurable and therefore needs to store a lot of per-event and per-organizer settings.
|
||||
Those settings are stored in the database and accessed through a ``SettingsProxy`` instance. You can obtain
|
||||
For this purpose, we use `django-hierarkey`_ which started out as part of pretix and then got refactored into
|
||||
its own library. It has a comprehensive `documentation`_ which you should read if you work with settings in pretix.
|
||||
|
||||
The settings are stored in the database and accessed through a ``HierarkeyProxy`` instance. You can obtain
|
||||
such an instance from any event or organizer model instance by just accessing ``event.settings`` or
|
||||
``organizer.settings``, respectively.
|
||||
|
||||
@@ -17,12 +20,10 @@ includes serializers for serializing the following types:
|
||||
|
||||
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
|
||||
convenience in templates you can also access settings values at ``settings[name]`` and ``settings.name``.
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsProxy
|
||||
:members: get, set, delete, freeze
|
||||
See the hierarkey `documentation`_ for more information.
|
||||
|
||||
To avoid naming conflicts, plugins are requested to prefix all settings they use with the name of the plugin
|
||||
or something unique, e.g. ``payment.paypal.api_key``. To reduce redundant typing of this prefix, we provide
|
||||
or something unique, e.g. ``payment_paypal_api_key``. To reduce redundant typing of this prefix, we provide
|
||||
another helper class:
|
||||
|
||||
.. autoclass:: pretix.base.settings.SettingsSandbox
|
||||
@@ -33,10 +34,10 @@ you will just be passed a sandbox object with a prefix generated from your provi
|
||||
Forms
|
||||
-----
|
||||
|
||||
We also provide a base class for forms that allow the modification of settings:
|
||||
Hierarkey also provides a base class for forms that allow the modification of settings. pretix contains a
|
||||
subclass that also adds suport for internationalized fields:
|
||||
|
||||
.. autoclass:: pretix.base.forms.SettingsForm
|
||||
:members: save
|
||||
|
||||
You can simply use it like this::
|
||||
|
||||
@@ -51,3 +52,17 @@ You can simply use it like this::
|
||||
help_text=_("The number of days after placing an order the user has to pay to "
|
||||
"preserve his reservation."),
|
||||
)
|
||||
|
||||
Defaults in plugins
|
||||
-------------------
|
||||
|
||||
Plugins can add custom hardcoded defaults in the following way::
|
||||
|
||||
from pretix.base.settings import settings_hierarkey
|
||||
|
||||
settings_hierarkey.add_default('key', 'value', type)
|
||||
|
||||
Make sure that you include this code in a module that is imported at app loading time.
|
||||
|
||||
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
|
||||
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
|
||||
@@ -83,6 +83,10 @@ As we did not implement an overall front page yet, you need to go directly to
|
||||
http://localhost:8000/control/ for the admin view or, if you imported the test
|
||||
data as suggested above, to the event page at http://localhost:8000/bigevents/2017/
|
||||
|
||||
.. note:: If you want the development server to listen on a different interface or
|
||||
port (for example because you develop on `pretixdroid`_), you can check
|
||||
`Django's documentation`_ for more options.
|
||||
|
||||
.. _`checksandtests`:
|
||||
|
||||
Code checks and unit tests
|
||||
@@ -148,3 +152,7 @@ To build the documentation, run the following command from the ``doc/`` director
|
||||
make html
|
||||
|
||||
You will now find the generated documentation in the ``doc/_build/html/`` subdirectory.
|
||||
|
||||
|
||||
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
|
||||
.. _pretixdroid: https://github.com/pretix/pretixdroid
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
@@ -68,7 +68,9 @@ class JSONExporter(BaseExporter):
|
||||
'variation': position.variation_id,
|
||||
'price': position.price,
|
||||
'attendee_name': position.attendee_name,
|
||||
'attendee_email': position.attendee_email,
|
||||
'secret': position.secret,
|
||||
'addon_to': position.addon_to_id,
|
||||
'answers': [
|
||||
{
|
||||
'question': answer.question_id,
|
||||
|
||||
@@ -4,6 +4,8 @@ from django import forms
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import OrderPosition
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..models import Order
|
||||
from ..signals import register_data_exporters
|
||||
@@ -16,7 +18,11 @@ class MailExporter(BaseExporter):
|
||||
def render(self, form_data: dict):
|
||||
qs = self.event.orders.filter(status__in=form_data['status'])
|
||||
addrs = qs.values('email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs))
|
||||
pos = OrderPosition.objects.filter(
|
||||
order__event=self.event, order__status__in=form_data['status']
|
||||
).values('attendee_email')
|
||||
data = "\r\n".join(set(a['email'] for a in addrs)
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
return 'pretixemails.txt', 'text/plain', data.encode("utf-8")
|
||||
|
||||
@property
|
||||
|
||||
@@ -59,7 +59,7 @@ class OrderListExporter(BaseExporter):
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'), _('Invoice numbers')
|
||||
_('Payment date'), _('Payment type'), _('Payment method fee'),
|
||||
]
|
||||
|
||||
for tr in tax_rates:
|
||||
@@ -69,6 +69,8 @@ class OrderListExporter(BaseExporter):
|
||||
_('Tax value at {rate} % tax').format(rate=tr),
|
||||
]
|
||||
|
||||
headers.append(_('Invoice numbers'))
|
||||
|
||||
writer.writerow(headers)
|
||||
|
||||
provider_names = {}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import logging
|
||||
|
||||
import i18nfield.forms
|
||||
from django import forms
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils import six
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
from pretix.base.models import Event
|
||||
|
||||
from .validators import PlaceholderValidator # NOQA
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.ticketoutputpdf')
|
||||
|
||||
|
||||
@@ -49,67 +47,22 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class SettingsForm(i18nfield.forms.I18nForm):
|
||||
"""
|
||||
This form is meant to be used for modifying EventSettings or OrganizerSettings. It takes
|
||||
care of loading the current values of the fields and saving the field inputs to the
|
||||
settings storage. It also deals with setting the available languages for internationalized
|
||||
fields.
|
||||
|
||||
:param obj: The event or organizer object which should be used for the settings storage
|
||||
"""
|
||||
|
||||
BOOL_CHOICES = (
|
||||
('False', _('disabled')),
|
||||
('True', _('enabled')),
|
||||
)
|
||||
class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = kwargs.pop('obj', None)
|
||||
self.locales = kwargs.pop('locales', None)
|
||||
kwargs['locales'] = self.obj.settings.get('locales') if self.obj else self.locales
|
||||
self.obj = kwargs.get('obj', None)
|
||||
self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None)
|
||||
kwargs['attribute_name'] = 'settings'
|
||||
kwargs['locales'] = self.locales
|
||||
kwargs['initial'] = self.obj.settings.freeze()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Performs the save operation
|
||||
"""
|
||||
for name, field in self.fields.items():
|
||||
value = self.cleaned_data[name]
|
||||
if isinstance(value, UploadedFile):
|
||||
# Delete old file
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
|
||||
# Create new file
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, value.name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, value.name.split('.')[-1])
|
||||
newname = default_storage.save(fname, value)
|
||||
value._name = newname
|
||||
self.obj.settings.set(name, value)
|
||||
elif isinstance(value, File):
|
||||
# file is unchanged
|
||||
continue
|
||||
elif isinstance(field, forms.FileField):
|
||||
# file is deleted
|
||||
fname = self.obj.settings.get(name, as_type=File)
|
||||
if fname:
|
||||
try:
|
||||
default_storage.delete(fname.name)
|
||||
except OSError:
|
||||
logger.error('Deleting file %s failed.' % fname.name)
|
||||
del self.obj.settings[name]
|
||||
elif value is None:
|
||||
del self.obj.settings[name]
|
||||
elif self.obj.settings.get(name, as_type=type(value)) != value:
|
||||
self.obj.settings.set(name, value)
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
nonce = get_random_string(length=8)
|
||||
if isinstance(self.obj, Event):
|
||||
fname = '%s/%s/%s.%s.%s' % (
|
||||
self.obj.organizer.slug, self.obj.slug, name, nonce, name.split('.')[-1]
|
||||
)
|
||||
else:
|
||||
fname = '%s/%s.%s.%s' % (self.obj.slug, name, nonce, name.split('.')[-1])
|
||||
return fname
|
||||
|
||||
38
src/pretix/base/forms/validators.py
Normal file
38
src/pretix/base/forms/validators.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
class PlaceholderValidator(BaseValidator):
|
||||
"""
|
||||
Takes list of allowed placeholders,
|
||||
validates form field by checking for placeholders,
|
||||
which are not presented in taken list.
|
||||
"""
|
||||
|
||||
def __init__(self, limit_value):
|
||||
super().__init__(limit_value)
|
||||
self.limit_value = limit_value
|
||||
|
||||
def __call__(self, value):
|
||||
if isinstance(value, LazyI18nString):
|
||||
for l, v in value.data.items():
|
||||
self.__call__(v)
|
||||
return
|
||||
|
||||
data_placeholders = list(re.findall(r'({[\w\s]*})', value, re.X))
|
||||
invalid_placeholders = []
|
||||
for placeholder in data_placeholders:
|
||||
if placeholder not in self.limit_value:
|
||||
invalid_placeholders.append(placeholder)
|
||||
if invalid_placeholders:
|
||||
raise ValidationError(
|
||||
_('Invalid placeholder(s): %(value)s'),
|
||||
code='invalid',
|
||||
params={'value': ", ".join(invalid_placeholders,)})
|
||||
|
||||
def clean(self, x):
|
||||
return x
|
||||
@@ -12,7 +12,12 @@ class Command(BaseCommand):
|
||||
call_command('compilejsi18n', verbosity=1, interactive=False)
|
||||
call_command('collectstatic', verbosity=1, interactive=False)
|
||||
call_command('compress', verbosity=1, interactive=False)
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
try:
|
||||
gs = GlobalSettingsObject()
|
||||
del gs.settings.update_check_last
|
||||
del gs.settings.update_check_result
|
||||
del gs.settings.update_check_result_warning
|
||||
except:
|
||||
# Fails when this is executed without a valid database configuration.
|
||||
# We don't care.
|
||||
pass
|
||||
|
||||
File diff suppressed because one or more lines are too long
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
59
src/pretix/base/migrations/0053_auto_20170409_1651.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-09 16:51
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_global_settings(apps, schema_editor):
|
||||
GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting')
|
||||
GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
|
||||
|
||||
l = []
|
||||
for s in GlobalSetting.objects.all():
|
||||
l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value))
|
||||
|
||||
GlobalSettingsObject_SettingsStore.objects.bulk_create(l)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0052_auto_20170324_1506'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='EventSetting',
|
||||
new_name='Event_SettingsStore',
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name='OrganizerSetting',
|
||||
new_name='Organizer_SettingsStore',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GlobalSettingsObject_SettingsStore',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(db_index=True, max_length=255)),
|
||||
('value', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_global_settings, migrations.RunPython.noop
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GlobalSetting',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='object',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'),
|
||||
),
|
||||
]
|
||||
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
40
src/pretix/base/migrations/0054_auto_20170413_1050.py
Normal file
File diff suppressed because one or more lines are too long
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
40
src/pretix/base/migrations/0055_auto_20170413_1537.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-13 15:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0054_auto_20170413_1050'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_email',
|
||||
field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='event_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='globalsettingsobject_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='organizer_settingsstore',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
]
|
||||
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
58
src/pretix/base/migrations/0056_auto_20170414_1044.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-04-14 10:44
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0055_auto_20170413_1537'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemAddOn',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')),
|
||||
('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemcategory',
|
||||
name='is_addon',
|
||||
field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='addon_to',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='free_price',
|
||||
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='addon_category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='base_item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='itemaddon',
|
||||
unique_together=set([('base_item', 'addon_category')]),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
30
src/pretix/base/migrations/0057_auto_20170501_2116.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.7 on 2017-05-01 21:16
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import i18nfield.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0056_auto_20170414_1044'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='itemaddon',
|
||||
options={'ordering': ('position', 'pk')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemaddon',
|
||||
name='position',
|
||||
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='description',
|
||||
field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,15 @@
|
||||
from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin
|
||||
from .event import (
|
||||
Event, EventLock, EventPermission, EventSetting, RequiredAction,
|
||||
Event, Event_SettingsStore, EventLock, EventPermission, RequiredAction,
|
||||
generate_invite_token,
|
||||
)
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
itempicture_upload_to,
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
Quota, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .orders import (
|
||||
@@ -17,6 +18,6 @@ from .orders import (
|
||||
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
|
||||
generate_secret,
|
||||
)
|
||||
from .organizer import Organizer, OrganizerPermission, OrganizerSetting
|
||||
from .organizer import Organizer, Organizer_SettingsStore, OrganizerPermission
|
||||
from .vouchers import Voucher
|
||||
from .waitinglist import WaitingListEntry
|
||||
|
||||
@@ -11,22 +11,21 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.fields import I18nCharField
|
||||
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.daterange import daterange
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
from .organizer import Organizer
|
||||
from .settings import EventSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(LoggedModel):
|
||||
"""
|
||||
This model represents an event. An event is anything you can buy
|
||||
@@ -59,6 +58,7 @@ class Event(LoggedModel):
|
||||
"""
|
||||
|
||||
settings_namespace = 'event'
|
||||
CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES]
|
||||
organizer = models.ForeignKey(Organizer, related_name="events", on_delete=models.PROTECT)
|
||||
name = I18nCharField(
|
||||
max_length=200,
|
||||
@@ -83,6 +83,7 @@ class Event(LoggedModel):
|
||||
related_name="events", )
|
||||
currency = models.CharField(max_length=10,
|
||||
verbose_name=_("Default currency"),
|
||||
choices=CURRENCY_CHOICES,
|
||||
default=settings.DEFAULT_CURRENCY)
|
||||
date_from = models.DateTimeField(verbose_name=_("Event start time"))
|
||||
date_to = models.DateTimeField(null=True, blank=True,
|
||||
@@ -181,17 +182,6 @@ class Event(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this event's settings.
|
||||
"""
|
||||
try:
|
||||
return SettingsProxy(self, type=EventSetting, parent=self.organizer)
|
||||
except Organizer.DoesNotExist:
|
||||
# Should only happen when creating new events
|
||||
return SettingsProxy(self, type=EventSetting)
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
if self.presale_end and now() > self.presale_end:
|
||||
@@ -235,7 +225,9 @@ class Event(LoggedModel):
|
||||
), tz)
|
||||
|
||||
def copy_data_from(self, other):
|
||||
from . import ItemCategory, Item, Question, Quota
|
||||
from . import ItemAddOn, ItemCategory, Item, Question, Quota
|
||||
from ..signals import event_copy_data
|
||||
|
||||
self.plugins = other.plugins
|
||||
self.save()
|
||||
|
||||
@@ -264,6 +256,12 @@ class Event(LoggedModel):
|
||||
v.item = i
|
||||
v.save()
|
||||
|
||||
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
|
||||
ia.pk = None
|
||||
ia.base_item = item_map[ia.base_item.pk]
|
||||
ia.addon_category = category_map[ia.addon_category.pk]
|
||||
ia.save()
|
||||
|
||||
for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'):
|
||||
items = list(q.items.all())
|
||||
vars = list(q.variations.all())
|
||||
@@ -271,7 +269,8 @@ class Event(LoggedModel):
|
||||
q.event = self
|
||||
q.save()
|
||||
for i in items:
|
||||
q.items.add(item_map[i.pk])
|
||||
if i.pk in item_map:
|
||||
q.items.add(item_map[i.pk])
|
||||
for v in vars:
|
||||
q.variations.add(variation_map[v.pk])
|
||||
|
||||
@@ -288,7 +287,7 @@ class Event(LoggedModel):
|
||||
o.question = q
|
||||
o.save()
|
||||
|
||||
for s in EventSetting.objects.filter(object=other):
|
||||
for s in other.settings._objects.all():
|
||||
s.object = self
|
||||
s.pk = None
|
||||
if s.value.startswith('file://'):
|
||||
@@ -301,6 +300,8 @@ class Event(LoggedModel):
|
||||
s.value = 'file://' + newname
|
||||
s.save()
|
||||
|
||||
event_copy_data.send(sender=self, other=other)
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from typing import Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Q, Sum
|
||||
from django.utils.functional import cached_property
|
||||
@@ -44,6 +45,13 @@ class ItemCategory(LoggedModel):
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
is_addon = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Products in this category are add-on products'),
|
||||
help_text=_('If selected, the products belonging to this category are not for sale on their own. They can '
|
||||
'only be bought in combination with a product that has this category configured as a possible '
|
||||
'source for add-ons.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product category")
|
||||
@@ -51,6 +59,8 @@ class ItemCategory(LoggedModel):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
def __str__(self):
|
||||
if self.is_addon:
|
||||
return _('{category} (Add-On products)').format(category=str(self.name))
|
||||
return str(self.name)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
@@ -113,6 +123,8 @@ class Item(LoggedModel):
|
||||
:type allow_cancel: bool
|
||||
:param max_per_order: Maximum number of times this item can be in an order. None for unlimited.
|
||||
:type max_per_order: int
|
||||
:param min_per_order: Minimum number of times this item needs to be in an order if bought at all. None for unlimited.
|
||||
:type min_per_order: int
|
||||
"""
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -153,7 +165,8 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Free price input"),
|
||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||
"additional donations for your event.")
|
||||
"additional donations for your event. This is currently not supported for products that are "
|
||||
"bought as an add-on to other products.")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
verbose_name=_("Taxes included in percent"),
|
||||
@@ -205,6 +218,12 @@ class Item(LoggedModel):
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
null=True, blank=True,
|
||||
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
|
||||
'the field empty or set it to 0, there is no special limit for this product.')
|
||||
)
|
||||
max_per_order = models.IntegerField(
|
||||
verbose_name=_('Maximum amount per order'),
|
||||
null=True, blank=True,
|
||||
@@ -212,6 +231,8 @@ class Item(LoggedModel):
|
||||
'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
|
||||
'number of items in the whole order applies regardless.')
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/views/item.py if applicable.
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Product")
|
||||
@@ -289,6 +310,8 @@ class ItemVariation(models.Model):
|
||||
:type item: Item
|
||||
:param value: A string defining this variation
|
||||
:type value: str
|
||||
:param description: A short description
|
||||
:type description: str
|
||||
:param active: Whether this variation is being sold.
|
||||
:type active: bool
|
||||
:param default_price: This variation's default price
|
||||
@@ -306,6 +329,11 @@ class ItemVariation(models.Model):
|
||||
default=True,
|
||||
verbose_name=_("Active"),
|
||||
)
|
||||
description = I18nTextField(
|
||||
verbose_name=_("Description"),
|
||||
help_text=_("This is shown below the variation name in lists."),
|
||||
null=True, blank=True,
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
@@ -369,6 +397,43 @@ class ItemVariation(models.Model):
|
||||
return self.position < other.position
|
||||
|
||||
|
||||
class ItemAddOn(models.Model):
|
||||
"""
|
||||
An instance of this model indicates that buying a ticket of the time ``base_item``
|
||||
allows you to add up to ``max_count`` items from the category ``addon_category``
|
||||
to your order that will be associated with the base item.
|
||||
"""
|
||||
base_item = models.ForeignKey(
|
||||
Item,
|
||||
related_name='addons'
|
||||
)
|
||||
addon_category = models.ForeignKey(
|
||||
ItemCategory,
|
||||
related_name='addon_to',
|
||||
verbose_name=_('Category')
|
||||
)
|
||||
min_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_('Minimum number')
|
||||
)
|
||||
max_count = models.PositiveIntegerField(
|
||||
default=1,
|
||||
verbose_name=_('Maximum number')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('base_item', 'addon_category'),)
|
||||
ordering = ('position', 'pk')
|
||||
|
||||
def clean(self):
|
||||
if self.max_count < self.min_count:
|
||||
raise ValidationError(_('The minimum number needs to be lower than the maximum number.'))
|
||||
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
@@ -623,6 +688,7 @@ class Quota(LoggedModel):
|
||||
func = 'GREATEST'
|
||||
|
||||
return Voucher.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(block_quota=True) &
|
||||
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
|
||||
Q(Q(self._position_lookup) | Q(quota=self))
|
||||
@@ -642,6 +708,7 @@ class Quota(LoggedModel):
|
||||
|
||||
now_dt = now_dt or now()
|
||||
return CartPosition.objects.filter(
|
||||
Q(event=self.event) &
|
||||
Q(expires__gte=now_dt) &
|
||||
~Q(
|
||||
Q(voucher__isnull=False) & Q(voucher__block_quota=True)
|
||||
@@ -655,14 +722,14 @@ class Quota(LoggedModel):
|
||||
|
||||
# This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin.
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING,
|
||||
self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
def count_paid_orders(self):
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
|
||||
return OrderPosition.objects.filter(
|
||||
self._position_lookup, order__status=Order.STATUS_PAID
|
||||
self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event
|
||||
).values('id').distinct().count()
|
||||
|
||||
@cached_property
|
||||
|
||||
@@ -394,6 +394,8 @@ class AbstractPosition(models.Model):
|
||||
:type price: decimal.Decimal
|
||||
:param attendee_name: The attendee's name, if entered.
|
||||
:type attendee_name: str
|
||||
:param attendee_email: The attendee's email, if entered.
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
:type voucher: Voucher
|
||||
"""
|
||||
@@ -418,9 +420,17 @@ class AbstractPosition(models.Model):
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher', null=True, blank=True
|
||||
)
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -486,13 +496,19 @@ class OrderPosition(AbstractPosition):
|
||||
from . import Voucher
|
||||
|
||||
ops = []
|
||||
for i, cartpos in enumerate(cp):
|
||||
cp_mapping = {}
|
||||
# The sorting key ensures that all addons come directly after the position they refer to
|
||||
for i, cartpos in enumerate(sorted(cp, key=lambda c: (c.addon_to_id or c.pk, c.addon_to_id or 0))):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
if f.name == 'addon_to':
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
cp_mapping[cartpos.pk] = op
|
||||
for answ in cartpos.answers.all():
|
||||
answ.orderposition = op
|
||||
answ.cartposition = None
|
||||
|
||||
@@ -3,17 +3,16 @@ import string
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.settings import SettingsProxy
|
||||
from pretix.base.validators import OrganizerSlugBlacklistValidator
|
||||
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
from .settings import OrganizerSetting
|
||||
|
||||
|
||||
@settings_hierarkey.add(cache_namespace='organizer')
|
||||
class Organizer(LoggedModel):
|
||||
"""
|
||||
This model represents an entity organizing events, e.g. a company, institution,
|
||||
@@ -59,14 +58,6 @@ class Organizer(LoggedModel):
|
||||
self.get_cache().clear()
|
||||
return obj
|
||||
|
||||
@cached_property
|
||||
def settings(self) -> SettingsProxy:
|
||||
"""
|
||||
Returns an object representing this organizer's settings
|
||||
"""
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
return SettingsProxy(self, type=OrganizerSetting, parent=GlobalSettingsObject())
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class GlobalSetting(models.Model):
|
||||
"""
|
||||
A global setting is a key-value setting which can be set for a
|
||||
pretix instance. It will be inherited by all events and organizers.
|
||||
It is filled via the register_global_settings signal.
|
||||
"""
|
||||
key = models.CharField(max_length=255, primary_key=True)
|
||||
value = models.TextField()
|
||||
|
||||
def __init__(self, *args, object=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class OrganizerSetting(models.Model):
|
||||
"""
|
||||
An organizer setting is a key-value setting which can be set for an
|
||||
organizer. It will be inherited by the events of this organizer
|
||||
"""
|
||||
object = models.ForeignKey('Organizer', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
|
||||
|
||||
class EventSetting(models.Model):
|
||||
"""
|
||||
An event setting is a key-value setting which can be set for a
|
||||
specific event
|
||||
"""
|
||||
object = models.ForeignKey('Event', related_name='setting_objects', on_delete=models.CASCADE)
|
||||
key = models.CharField(max_length=255)
|
||||
value = models.TextField()
|
||||
@@ -1,4 +1,4 @@
|
||||
from collections import Counter, namedtuple
|
||||
from collections import Counter, defaultdict, namedtuple
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
@@ -27,6 +27,7 @@ error_messages = {
|
||||
'busy': _('We were not able to process your request completely as the '
|
||||
'server was too busy. Please try again.'),
|
||||
'empty': _('You did not select any products.'),
|
||||
'unknown_position': _('Unknown cart position.'),
|
||||
'not_for_sale': _('You selected a product which is not available for sale.'),
|
||||
'unavailable': _('Some of the products you selected are no longer available. '
|
||||
'Please see below for details.'),
|
||||
@@ -34,6 +35,9 @@ error_messages = {
|
||||
'the quantity you selected. Please see below for details.'),
|
||||
'max_items': _("You cannot select more than %s items per order."),
|
||||
'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s."),
|
||||
'min_items_per_product': _("You need to select at least %(min)s items of the product %(product)s."),
|
||||
'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than "
|
||||
"%(min)s items of it."),
|
||||
'not_started': _('The presale period for this event has not yet started.'),
|
||||
'ended': _('The presale period has ended.'),
|
||||
'price_too_high': _('The entered price is to high.'),
|
||||
@@ -45,11 +49,18 @@ error_messages = {
|
||||
'voucher_expired': _('This voucher is expired.'),
|
||||
'voucher_invalid_item': _('This voucher is not valid for this product.'),
|
||||
'voucher_required': _('You need a valid voucher code to order this product.'),
|
||||
'addon_invalid_base': _('You can not select an add-on for the selected product.'),
|
||||
'addon_duplicate_item': _('You can not select two variations of the same add-on product.'),
|
||||
'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'),
|
||||
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
|
||||
'product %(base)s.'),
|
||||
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
|
||||
}
|
||||
|
||||
|
||||
class CartManager:
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas'))
|
||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
|
||||
'addon_to'))
|
||||
RemoveOperation = namedtuple('RemoveOperation', ('position',))
|
||||
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
|
||||
'quotas'))
|
||||
@@ -74,7 +85,7 @@ class CartManager:
|
||||
def positions(self):
|
||||
return CartPosition.objects.filter(
|
||||
Q(cart_id=self.cart_id) & Q(event=self.event)
|
||||
)
|
||||
).select_related('item')
|
||||
|
||||
def _calculate_expiry(self):
|
||||
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
|
||||
@@ -97,9 +108,15 @@ class CartManager:
|
||||
|
||||
def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]):
|
||||
self._items_cache.update(
|
||||
{i.pk: i for i in self.event.items.prefetch_related('quotas').filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)}
|
||||
{
|
||||
i.pk: i
|
||||
for i
|
||||
in self.event.items.select_related('category').prefetch_related(
|
||||
'addons', 'addons__addon_category', 'quotas'
|
||||
).filter(
|
||||
id__in=[i for i in item_ids if i and i not in self._items_cache]
|
||||
)
|
||||
}
|
||||
)
|
||||
self._variations_cache.update(
|
||||
{v.pk: v for v in
|
||||
@@ -111,9 +128,10 @@ class CartManager:
|
||||
)
|
||||
|
||||
def _check_max_cart_size(self):
|
||||
cartsize = self.positions.count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation)])
|
||||
cartsize = self.positions.filter(addon_to__isnull=True).count()
|
||||
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
|
||||
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
|
||||
not op.position.addon_to_id])
|
||||
if cartsize > int(self.event.settings.max_items_per_order):
|
||||
# TODO: i18n plurals
|
||||
raise CartError(_(error_messages['max_items']) % (self.event.settings.max_items_per_order,))
|
||||
@@ -133,7 +151,10 @@ class CartManager:
|
||||
raise CartError(error_messages['voucher_invalid_item'])
|
||||
|
||||
if isinstance(op, self.AddOperation):
|
||||
if op.item.max_per_order:
|
||||
if op.item.category and op.item.category.is_addon and not op.addon_to:
|
||||
raise CartError(error_messages['addon_only'])
|
||||
|
||||
if op.item.max_per_order or op.item.min_per_order:
|
||||
new_total = (
|
||||
len([1 for p in self.positions if p.item_id == op.item.pk]) +
|
||||
sum([_op.count for _op in self._operations
|
||||
@@ -143,13 +164,21 @@ class CartManager:
|
||||
if isinstance(_op, self.RemoveOperation) and _op.position.item_id == op.item.pk])
|
||||
)
|
||||
|
||||
if new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
if op.item.max_per_order and new_total > op.item.max_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['max_items_per_product']) % {
|
||||
'max': op.item.max_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
if op.item.min_per_order and new_total < op.item.min_per_order:
|
||||
raise CartError(
|
||||
_(error_messages['min_items_per_product']) % {
|
||||
'min': op.item.min_per_order,
|
||||
'product': op.item.name
|
||||
}
|
||||
)
|
||||
|
||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||
voucher: Optional[Voucher], custom_price: Optional[Decimal]):
|
||||
@@ -236,7 +265,8 @@ class CartManager:
|
||||
|
||||
price = self._get_price(item, variation, voucher, i.get('price'))
|
||||
op = self.AddOperation(
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas
|
||||
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
|
||||
addon_to=False
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
@@ -245,29 +275,144 @@ class CartManager:
|
||||
self._voucher_use_diff += voucher_use_diff
|
||||
self._operations += operations
|
||||
|
||||
def remove_items(self, items: List[dict]):
|
||||
def remove_item(self, pos_id: int):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for i in items:
|
||||
cw = Q(cart_id=self.cart_id) & Q(item_id=i['item']) & Q(event=self.event)
|
||||
if i['variation']:
|
||||
cw &= Q(variation_id=i['variation'])
|
||||
else:
|
||||
cw &= Q(variation__isnull=True)
|
||||
# Prefer to delete positions that have the same price as the one the user clicked on, after thet
|
||||
# prefer the most expensive ones.
|
||||
cnt = i['count']
|
||||
if i['price']:
|
||||
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
|
||||
for cp in correctprice:
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
cnt -= len(correctprice)
|
||||
if cnt > 0:
|
||||
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
try:
|
||||
cp = self.positions.get(pk=pos_id)
|
||||
except CartPosition.DoesNotExist:
|
||||
raise CartError(error_messages['unknown_position'])
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def clear(self):
|
||||
# TODO: We could calculate quotadiffs and voucherdiffs here, which would lead to more
|
||||
# flexible usages (e.g. a RemoveOperation and an AddOperation in the same transaction
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for cp in self.positions.all():
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def set_addons(self, addons):
|
||||
self._update_items_cache(
|
||||
[a['item'] for a in addons],
|
||||
[a['variation'] for a in addons],
|
||||
)
|
||||
|
||||
# Prepare various containers to hold data later
|
||||
current_addons = defaultdict(dict) # CartPos -> currently attached add-ons
|
||||
input_addons = defaultdict(set) # CartPos -> add-ons according to input
|
||||
selected_addons = defaultdict(set) # CartPos -> final desired set of add-ons
|
||||
cpcache = {} # CartPos.pk -> CartPos
|
||||
quota_diff = Counter() # Quota -> Number of usages
|
||||
operations = []
|
||||
available_categories = defaultdict(set) # CartPos -> Category IDs to choose from
|
||||
toplevel_cp = self.positions.filter(
|
||||
addon_to__isnull=True
|
||||
).prefetch_related(
|
||||
'addons', 'item__addons', 'item__addons__addon_category'
|
||||
).select_related('item', 'variation')
|
||||
|
||||
# Prefill some of the cache containers
|
||||
for cp in toplevel_cp:
|
||||
available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()}
|
||||
cpcache[cp.pk] = cp
|
||||
current_addons[cp] = {
|
||||
(a.item_id, a.variation_id): a
|
||||
for a in cp.addons.all()
|
||||
}
|
||||
|
||||
# Create operations, perform various checks
|
||||
for a in addons:
|
||||
# Check whether the specified items are part of what we just fetched from the database
|
||||
# If they are not, the user supplied item IDs which either do not exist or belong to
|
||||
# a different event
|
||||
if a['item'] not in self._items_cache or (a['variation'] and a['variation'] not in self._variations_cache):
|
||||
raise CartError(error_messages['not_for_sale'])
|
||||
|
||||
# Only attach addons to things that are actually in this user's cart
|
||||
if a['addon_to'] not in cpcache:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
cp = cpcache[a['addon_to']]
|
||||
item = self._items_cache[a['item']]
|
||||
variation = self._variations_cache[a['variation']] if a['variation'] is not None else None
|
||||
|
||||
if item.category_id not in available_categories[cp.pk]:
|
||||
raise CartError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
|
||||
if not quotas:
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
# Every item can be attached to very CartPosition at most once
|
||||
if a['item'] in ([_a[0] for _a in input_addons[cp.id]]):
|
||||
raise CartError(error_messages['addon_duplicate_item'])
|
||||
|
||||
input_addons[cp.id].add((a['item'], a['variation']))
|
||||
selected_addons[cp.id, item.category_id].add((a['item'], a['variation']))
|
||||
|
||||
if (a['item'], a['variation']) not in current_addons[cp]:
|
||||
# This add-on is new, add it to the cart
|
||||
for quota in quotas:
|
||||
quota_diff[quota] += 1
|
||||
|
||||
price = self._get_price(item, variation, None, None)
|
||||
|
||||
op = self.AddOperation(
|
||||
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
|
||||
addon_to=cp
|
||||
)
|
||||
self._check_item_constraints(op)
|
||||
operations.append(op)
|
||||
|
||||
# Check constraints on the add-on combinations
|
||||
for cp in toplevel_cp:
|
||||
item = cp.item
|
||||
for iao in item.addons.all():
|
||||
selected = selected_addons[cp.id, iao.addon_category_id]
|
||||
if len(selected) > iao.max_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_max_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'max': iao.max_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
elif len(selected) < iao.min_count:
|
||||
# TODO: Proper i18n
|
||||
# TODO: Proper pluralization
|
||||
raise CartError(
|
||||
error_messages['addon_min_count'],
|
||||
{
|
||||
'base': str(item.name),
|
||||
'min': iao.min_count,
|
||||
'cat': str(iao.addon_category.name),
|
||||
}
|
||||
)
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in current_addons.items():
|
||||
for k, v in al.items():
|
||||
if k not in input_addons[cp.id]:
|
||||
if v.expires > self.now_dt:
|
||||
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
|
||||
|
||||
for quota in quotas:
|
||||
quota_diff[quota] -= 1
|
||||
|
||||
op = self.RemoveOperation(position=v)
|
||||
operations.append(op)
|
||||
|
||||
self._quota_diff += quota_diff
|
||||
self._operations += operations
|
||||
|
||||
def _get_quota_availability(self):
|
||||
quotas_ok = {}
|
||||
@@ -298,12 +443,47 @@ class CartManager:
|
||||
|
||||
return vouchers_ok
|
||||
|
||||
def _check_min_per_product(self):
|
||||
per_product = Counter()
|
||||
min_per_product = {}
|
||||
for p in self.positions:
|
||||
per_product[p.item_id] += 1
|
||||
min_per_product[p.item.pk] = p.item.min_per_order
|
||||
|
||||
for op in self._operations:
|
||||
if isinstance(op, self.AddOperation):
|
||||
per_product[op.item.pk] += op.count
|
||||
min_per_product[op.item.pk] = op.item.min_per_order
|
||||
elif isinstance(op, self.RemoveOperation):
|
||||
per_product[op.position.item_id] -= 1
|
||||
min_per_product[op.position.item.pk] = op.position.item.min_per_order
|
||||
|
||||
err = None
|
||||
for itemid, num in per_product.items():
|
||||
min_p = min_per_product[itemid]
|
||||
if min_p and num < min_p:
|
||||
self._operations = [o for o in self._operations if not (
|
||||
isinstance(o, self.AddOperation) and o.item.pk == itemid
|
||||
)]
|
||||
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
|
||||
for p in self.positions:
|
||||
if p.item_id == itemid and p.pk not in removals:
|
||||
self._operations.append(self.RemoveOperation(position=p))
|
||||
err = _(error_messages['min_items_per_product_removed']) % {
|
||||
'min': min_p,
|
||||
'product': p.item.name
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
def _perform_operations(self):
|
||||
vouchers_ok = self._get_voucher_availability()
|
||||
quotas_ok = self._get_quota_availability()
|
||||
err = None
|
||||
new_cart_positions = []
|
||||
|
||||
err = err or self._check_min_per_product()
|
||||
|
||||
self._operations.sort(key=lambda a: self.order[type(a)])
|
||||
|
||||
for op in self._operations:
|
||||
@@ -342,7 +522,8 @@ class CartManager:
|
||||
new_cart_positions.append(CartPosition(
|
||||
event=self.event, item=op.item, variation=op.variation,
|
||||
price=op.price, expires=self._expiry,
|
||||
cart_id=self.cart_id, voucher=op.voucher
|
||||
cart_id=self.cart_id, voucher=op.voucher,
|
||||
addon_to=op.addon_to if op.addon_to else None
|
||||
))
|
||||
elif isinstance(op, self.ExtendOperation):
|
||||
if available_count == 1:
|
||||
@@ -377,7 +558,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher)
|
||||
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
|
||||
:param session: Session ID of a guest
|
||||
:param coupon: A coupon that should also be reeemed
|
||||
:raises CartError: On any error that occured
|
||||
@@ -396,11 +577,11 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param items: A list of tuple of the form (item id, variation id or None, number)
|
||||
:param position: A cart position ID
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
@@ -408,7 +589,48 @@ def remove_items_from_cart(self, event: int, items: List[dict], cart_id: str=Non
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.remove_items(items)
|
||||
cm.remove_item(position)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.clear()
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
raise CartError(error_messages['busy'])
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en') -> None:
|
||||
"""
|
||||
Removes a list of items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
:param addons: A list of dicts with the keys addon_to, item, variation
|
||||
:param session: Session ID of a guest
|
||||
"""
|
||||
with language(locale):
|
||||
event = Event.objects.get(id=event)
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id)
|
||||
cm.set_addons(addons)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
|
||||
@@ -21,3 +21,4 @@ def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) ->
|
||||
file.filename, file.type, data = ex.render(form_data)
|
||||
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
|
||||
file.save()
|
||||
return file.pk
|
||||
|
||||
@@ -72,6 +72,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
if p.addon_to_id:
|
||||
desc = " + " + desc
|
||||
InvoiceLine.objects.create(
|
||||
invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
@@ -200,6 +202,14 @@ def _invoice_generate_german(invoice, f):
|
||||
p_size = p.wrap(85 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if invoice.is_cancellation:
|
||||
@@ -243,20 +253,6 @@ def _invoice_generate_german(invoice, f):
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(165 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.full_code)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
if invoice.event.settings.invoice_logo_image:
|
||||
logo_file = invoice.event.settings.get('invoice_logo_image', binary_file=True)
|
||||
canvas.drawImage(ImageReader(logo_file),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
import bleach
|
||||
import cssutils
|
||||
@@ -138,7 +138,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
|
||||
|
||||
@app.task
|
||||
def mail_send_task(to: str, subject: str, body: str, html: str, sender: str,
|
||||
def mail_send_task(to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, headers=headers)
|
||||
email.attach_alternative(inline_css(html), "text/html")
|
||||
|
||||
@@ -562,6 +562,7 @@ class OrderChangeManager:
|
||||
'new_item': op.item.pk,
|
||||
'new_variation': op.variation.pk if op.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.item = op.item
|
||||
@@ -574,18 +575,29 @@ class OrderChangeManager:
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': op.position.addon_to_id,
|
||||
'new_price': op.price
|
||||
})
|
||||
op.position.price = op.price
|
||||
op.position._calculate_tax()
|
||||
op.position.save()
|
||||
elif isinstance(op, self.CancelOperation):
|
||||
for opa in op.position.addons.all():
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': opa.pk,
|
||||
'positionid': opa.positionid,
|
||||
'old_item': opa.item.pk,
|
||||
'old_variation': opa.variation.pk if opa.variation else None,
|
||||
'addon_to': opa.addon_to_id,
|
||||
'old_price': opa.price,
|
||||
})
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
'old_item': op.position.item.pk,
|
||||
'old_variation': op.position.variation.pk if op.position.variation else None,
|
||||
'old_price': op.position.price,
|
||||
'addon_to': None,
|
||||
})
|
||||
op.position.delete()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ def assign_automatically(event_id: int, user_id: int=None):
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def process_waitinglist(sender, **kwargs):
|
||||
qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer')
|
||||
qs = Event.objects.prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
|
||||
for e in qs:
|
||||
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto:
|
||||
assign_automatically.apply_async(args=(e.pk,))
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import decimal
|
||||
import json
|
||||
from datetime import date, datetime, time
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import dateutil.parser
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import ugettext_noop
|
||||
|
||||
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pretix.base.models.settings import GlobalSetting
|
||||
from typing import Any
|
||||
|
||||
DEFAULTS = {
|
||||
'max_items_per_order': {
|
||||
@@ -32,6 +27,14 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'attendee_emails_asked': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'attendee_emails_required': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'invoice_address_asked': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
@@ -156,6 +159,10 @@ DEFAULTS = {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
},
|
||||
'ticket_download_addons': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'last_order_modification_date': {
|
||||
'default': None,
|
||||
'type': datetime
|
||||
@@ -382,194 +389,27 @@ Your {event} team"""))
|
||||
}
|
||||
}
|
||||
|
||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||
|
||||
class SettingsProxy:
|
||||
"""
|
||||
This object allows convenient access to settings stored in the
|
||||
EventSettings/OrganizerSettings database model. It exposes all settings as
|
||||
properties and it will do all the nasty inheritance and defaults stuff for
|
||||
you.
|
||||
"""
|
||||
for k, v in DEFAULTS.items():
|
||||
settings_hierarkey.add_default(k, v['default'], v['type'])
|
||||
|
||||
def __init__(self, obj: Model, parent: Optional[Model]=None, type=None):
|
||||
self._obj = obj
|
||||
self._parent = parent
|
||||
self._cached_obj = None
|
||||
self._write_cached_obj = None
|
||||
self._type = type
|
||||
|
||||
def _cache(self) -> Dict[str, Any]:
|
||||
if self._cached_obj is None:
|
||||
self._cached_obj = cache.get_or_set(
|
||||
'settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk),
|
||||
lambda: {s.key: s.value for s in self._obj.setting_objects.all()},
|
||||
timeout=1800
|
||||
)
|
||||
return self._cached_obj
|
||||
def i18n_uns(v):
|
||||
try:
|
||||
return LazyI18nString(json.loads(v))
|
||||
except ValueError:
|
||||
return LazyI18nString(str(v))
|
||||
|
||||
def _write_cache(self) -> Dict[str, Any]:
|
||||
if self._write_cached_obj is None:
|
||||
self._write_cached_obj = {
|
||||
s.key: s for s in self._obj.setting_objects.all()
|
||||
}
|
||||
return self._write_cached_obj
|
||||
|
||||
def _flush(self) -> None:
|
||||
self._cached_obj = None
|
||||
self._write_cached_obj = None
|
||||
self._flush_external_cache()
|
||||
settings_hierarkey.add_type(LazyI18nString,
|
||||
serialize=lambda s: json.dumps(s.data),
|
||||
unserialize=i18n_uns)
|
||||
|
||||
def _flush_external_cache(self):
|
||||
cache.delete('settings_{}_{}'.format(self._obj.settings_namespace, self._obj.pk))
|
||||
|
||||
def freeze(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of all settings set for this object, including
|
||||
any default values of its parents or hardcoded in pretix.
|
||||
"""
|
||||
settings = {}
|
||||
for key, v in DEFAULTS.items():
|
||||
settings[key] = self._unserialize(v['default'], v['type'])
|
||||
if self._parent:
|
||||
settings.update(self._parent.settings.freeze())
|
||||
for key in self._cache():
|
||||
settings[key] = self.get(key)
|
||||
return settings
|
||||
|
||||
def _unserialize(self, value: str, as_type: type, binary_file=False) -> Any:
|
||||
if as_type is None and value is not None and value.startswith('file://'):
|
||||
as_type = File
|
||||
|
||||
if as_type is not None and isinstance(value, as_type):
|
||||
return value
|
||||
elif value is None:
|
||||
return None
|
||||
elif as_type == int or as_type == float or as_type == decimal.Decimal:
|
||||
return as_type(value)
|
||||
elif as_type == dict or as_type == list:
|
||||
return json.loads(value)
|
||||
elif as_type == bool or value in ('True', 'False'):
|
||||
return value == 'True'
|
||||
elif as_type == File:
|
||||
try:
|
||||
fi = default_storage.open(value[7:], 'rb' if binary_file else 'r')
|
||||
fi.url = default_storage.url(value[7:])
|
||||
return fi
|
||||
except OSError:
|
||||
return False
|
||||
elif as_type == datetime:
|
||||
return dateutil.parser.parse(value)
|
||||
elif as_type == date:
|
||||
return dateutil.parser.parse(value).date()
|
||||
elif as_type == time:
|
||||
return dateutil.parser.parse(value).time()
|
||||
elif as_type == LazyI18nString and not isinstance(value, LazyI18nString):
|
||||
try:
|
||||
return LazyI18nString(json.loads(value))
|
||||
except ValueError:
|
||||
return LazyI18nString(str(value))
|
||||
elif as_type is not None and issubclass(as_type, Model):
|
||||
return as_type.objects.get(pk=value)
|
||||
return value
|
||||
|
||||
def _serialize(self, value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, int) or isinstance(value, float) \
|
||||
or isinstance(value, bool) or isinstance(value, decimal.Decimal):
|
||||
return str(value)
|
||||
elif isinstance(value, list) or isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
elif isinstance(value, datetime) or isinstance(value, date) or isinstance(value, time):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, Model):
|
||||
return value.pk
|
||||
elif isinstance(value, LazyI18nString):
|
||||
return json.dumps(value.data)
|
||||
elif isinstance(value, File):
|
||||
return 'file://' + value.name
|
||||
|
||||
raise TypeError('Unable to serialize %s into a setting.' % str(type(value)))
|
||||
|
||||
def get(self, key: str, default=None, as_type: type=None, binary_file=False):
|
||||
"""
|
||||
Get a setting specified by key ``key``. Normally, settings are strings, but
|
||||
if you put non-strings into the settings object, you can request unserialization
|
||||
by specifying ``as_type``. If the key does not have a harcdoded type in the pretix source,
|
||||
omitting ``as_type`` always will get you a string.
|
||||
|
||||
If the setting with the specified name does not exist on this object, any parent object
|
||||
will be queried (e.g. the organizer of an event). If still no value is found, a default
|
||||
value hardcoded will be returned if one exists. If not, the value of the ``default`` argument
|
||||
will be returned instead.
|
||||
"""
|
||||
if as_type is None and key in DEFAULTS:
|
||||
as_type = DEFAULTS[key]['type']
|
||||
|
||||
if key in self._cache():
|
||||
value = self._cache()[key]
|
||||
else:
|
||||
value = None
|
||||
if self._parent:
|
||||
value = self._parent.settings.get(key, as_type=str)
|
||||
if value is None and key in DEFAULTS:
|
||||
value = DEFAULTS[key]['default']
|
||||
if value is None and default is not None:
|
||||
value = default
|
||||
|
||||
return self._unserialize(value, as_type, binary_file=binary_file)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.get(key)
|
||||
|
||||
def __getattr__(self, key: str) -> Any:
|
||||
if key.startswith('_'):
|
||||
return super().__getattr__(key)
|
||||
return self.get(key)
|
||||
|
||||
def __setattr__(self, key: str, value: Any) -> None:
|
||||
if key.startswith('_'):
|
||||
return super().__setattr__(key, value)
|
||||
self.set(key, value)
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self.set(key, value)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Stores a setting to the database of its object.
|
||||
"""
|
||||
wc = self._write_cache()
|
||||
if key in wc:
|
||||
s = wc[key]
|
||||
else:
|
||||
s = self._type(object=self._obj, key=key)
|
||||
s.value = self._serialize(value)
|
||||
s.save()
|
||||
self._cache()[key] = s.value
|
||||
wc[key] = s
|
||||
self._flush_external_cache()
|
||||
|
||||
def __delattr__(self, key: str) -> None:
|
||||
if key.startswith('_'):
|
||||
return super().__delattr__(key)
|
||||
self.delete(key)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
self.delete(key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""
|
||||
Deletes a setting from this object's storage.
|
||||
"""
|
||||
if key in self._write_cache():
|
||||
self._write_cache()[key].delete()
|
||||
del self._write_cache()[key]
|
||||
|
||||
if key in self._cache():
|
||||
del self._cache()[key]
|
||||
|
||||
self._flush_external_cache()
|
||||
@settings_hierarkey.set_global(cache_namespace='global')
|
||||
class GlobalSettingsObject(GlobalSettingsBase):
|
||||
slug = '_global'
|
||||
|
||||
|
||||
class SettingsSandbox:
|
||||
@@ -614,13 +454,3 @@ class SettingsSandbox:
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
self._event.settings.set(self._convert_key(key), value)
|
||||
|
||||
|
||||
class GlobalSettingsObject():
|
||||
settings_namespace = 'global'
|
||||
|
||||
def __init__(self):
|
||||
self.settings = SettingsProxy(self, type=GlobalSetting)
|
||||
self.setting_objects = GlobalSetting.objects
|
||||
self.slug = '_global'
|
||||
self.pk = '_global'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import warnings
|
||||
from typing import Any, Callable, List, Tuple
|
||||
|
||||
import django.dispatch
|
||||
@@ -52,6 +53,13 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
return responses
|
||||
|
||||
|
||||
class DeprecatedSignal(django.dispatch.Signal):
|
||||
|
||||
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
|
||||
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
|
||||
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
|
||||
|
||||
|
||||
event_live_issues = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
@@ -153,6 +161,21 @@ to the user. The receivers are expected to return HTML code.
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
event_copy_data = EventPluginSignal(
|
||||
providing_args=["other"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out when a new event is created as a clone of an existing event, i.e.
|
||||
the settings from the older event are copied to the newer one. You can listen to this
|
||||
signal to copy data or configuration stored within your plugin's models as well.
|
||||
|
||||
You don't need to copy data inside the general settings storage which is cloned automatically,
|
||||
but you might need to modify that data.
|
||||
|
||||
The ``sender`` keyword argument will contain the event of the **new** event. The ``other``
|
||||
keyword argument will contain the event to **copy from**.
|
||||
"""
|
||||
|
||||
periodic_task = django.dispatch.Signal()
|
||||
"""
|
||||
This is a regular django signal (no pretix event signal) that we send out every
|
||||
|
||||
@@ -11,6 +11,7 @@ ALLOWED_TAGS = [
|
||||
'acronym',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'em',
|
||||
'i',
|
||||
|
||||
@@ -51,10 +51,15 @@ class BaseTicketOutput:
|
||||
This method should generate a download file and return a tuple consisting of a
|
||||
filename, a file type and file content. The extension will be taken from the filename
|
||||
which is otherwise ignored.
|
||||
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
|
||||
continue
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
|
||||
@@ -72,6 +72,7 @@ class AsyncAction:
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_success_url(res.info),
|
||||
'success': True,
|
||||
'message': str(self.get_success_message(res.info))
|
||||
})
|
||||
else:
|
||||
@@ -80,6 +81,7 @@ class AsyncAction:
|
||||
# but handle the mssage itself
|
||||
data.update({
|
||||
'redirect': self.get_error_url(),
|
||||
'success': False,
|
||||
'message': str(self.get_error_message(res.info))
|
||||
})
|
||||
return data
|
||||
@@ -103,6 +105,7 @@ class AsyncAction:
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
'ready': True,
|
||||
'success': True,
|
||||
'redirect': self.get_success_url(value),
|
||||
'message': str(self.get_success_message(value))
|
||||
})
|
||||
@@ -113,6 +116,7 @@ class AsyncAction:
|
||||
if "ajax" in self.request.POST or "ajax" in self.request.GET:
|
||||
return JsonResponse({
|
||||
'ready': True,
|
||||
'success': False,
|
||||
'redirect': self.get_error_url(),
|
||||
'message': str(self.get_error_message(exception))
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
|
||||
from .signals import html_head, nav_event, nav_topbar
|
||||
from .signals import html_head, nav_event, nav_global, nav_topbar
|
||||
from .utils.i18n import get_javascript_format, get_moment_locale
|
||||
|
||||
|
||||
@@ -38,8 +38,14 @@ def contextprocessor(request):
|
||||
_nav_event += response
|
||||
if request.event.settings.get('payment_term_weekdays'):
|
||||
_js_payment_weekdays_disabled = '[0,6]'
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
ctx['nav_event'] = _nav_event
|
||||
ctx['js_payment_weekdays_disabled'] = _js_payment_weekdays_disabled
|
||||
|
||||
_nav_global = []
|
||||
if not hasattr(request, 'event'):
|
||||
for receiver, response in nav_global.send(request, request=request):
|
||||
_nav_global += response
|
||||
ctx['nav_global'] = _nav_global
|
||||
|
||||
_nav_topbar = []
|
||||
for receiver, response in nav_topbar.send(request, request=request):
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from pytz import common_timezones, timezone
|
||||
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.control.forms import ExtFileField
|
||||
|
||||
@@ -61,9 +61,11 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_basics-date_from'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_basics-presale_start'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -151,9 +153,10 @@ class EventUpdateForm(I18nModelForm):
|
||||
]
|
||||
widgets = {
|
||||
'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}),
|
||||
'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-date-after': '#id_presale_start'}),
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +185,7 @@ class EventSettingsForm(SettingsForm):
|
||||
presale_start_show_date = forms.BooleanField(
|
||||
label=_("Show start date"),
|
||||
help_text=_("Show the presale start date before presale has started."),
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_presale_start'}),
|
||||
required=False
|
||||
)
|
||||
last_order_modification_date = forms.DateTimeField(
|
||||
@@ -221,28 +225,48 @@ class EventSettingsForm(SettingsForm):
|
||||
min_value=6,
|
||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.NumberInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
)
|
||||
waiting_list_auto = forms.BooleanField(
|
||||
label=_("Automatic waiting list assignments"),
|
||||
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
|
||||
"on the waiting list for that product. If this is not active, mails will not be send automatically "
|
||||
"but you can send them manually via the control panel."),
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-waiting_list_enabled'}),
|
||||
)
|
||||
attendee_names_asked = forms.BooleanField(
|
||||
label=_("Ask for attendee names"),
|
||||
help_text=_("Ask for a name for all tickets which include admission to the event."),
|
||||
required=False
|
||||
required=False,
|
||||
)
|
||||
attendee_names_required = forms.BooleanField(
|
||||
label=_("Require attendee names"),
|
||||
help_text=_("Require customers to fill in the names of all attendees."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
|
||||
)
|
||||
attendee_emails_asked = forms.BooleanField(
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be send "
|
||||
"to that email address. If you enable this option, the system will additionally ask for "
|
||||
"individual email addresses for every admission ticket. This might be useful if you want to "
|
||||
"obtain individual addresses for every attendee even in case of group orders."),
|
||||
required=False
|
||||
)
|
||||
attendee_emails_required = forms.BooleanField(
|
||||
label=_("Require email addresses per ticket"),
|
||||
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
|
||||
"above option for more details. One email address for the order confirmation will always be "
|
||||
"required regardless of this setting."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
|
||||
)
|
||||
max_items_per_order = forms.IntegerField(
|
||||
min_value=1,
|
||||
label=_("Maximum number of items per order")
|
||||
label=_("Maximum number of items per order"),
|
||||
help_text=_("Add-on products will not be counted.")
|
||||
)
|
||||
reservation_time = forms.IntegerField(
|
||||
min_value=0,
|
||||
@@ -274,6 +298,10 @@ class EventSettingsForm(SettingsForm):
|
||||
raise ValidationError({
|
||||
'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.')
|
||||
})
|
||||
if data['attendee_emails_required'] and not data['attendee_emails_asked']:
|
||||
raise ValidationError({
|
||||
'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.')
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
@@ -346,7 +374,7 @@ class ProviderForm(SettingsForm):
|
||||
if isinstance(v, I18nFormField):
|
||||
v._required = v.one_required
|
||||
v.one_required = False
|
||||
v.widget.enabled_langcodes = self.obj.settings.get('locales')
|
||||
v.widget.enabled_locales = self.locales
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -366,11 +394,13 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
)
|
||||
invoice_address_required = forms.BooleanField(
|
||||
label=_("Require invoice address"),
|
||||
required=False
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
)
|
||||
invoice_address_vatid = forms.BooleanField(
|
||||
label=_("Ask for VAT ID"),
|
||||
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_numbers_consecutive = forms.BooleanField(
|
||||
@@ -442,37 +472,44 @@ class MailSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {total}, {currency}, {date}, {paymentinfo}, {url}, "
|
||||
"{invoice_name}, {invoice_company}")
|
||||
"{invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{total}', '{currency}', '{date}', '{paymentinfo}',
|
||||
'{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_order_paid = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
|
||||
)
|
||||
mail_text_order_free = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_order_changed = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_link = I18nFormField(
|
||||
label=_("Text (sent by admin)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_resend_all_links = I18nFormField(
|
||||
label=_("Text (requested by user)"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {orders}")
|
||||
help_text=_("Available placeholders: {event}, {orders}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{orders}'])]
|
||||
)
|
||||
mail_days_order_expire_warning = forms.IntegerField(
|
||||
label=_("Number of days"),
|
||||
@@ -485,13 +522,15 @@ class MailSettingsForm(SettingsForm):
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{expire_date}', '{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
mail_text_waiting_list = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}")
|
||||
help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}"),
|
||||
validators=[PlaceholderValidator(['{event}', '{url}', '{product}', '{hours}', '{code}'])]
|
||||
)
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
@@ -577,7 +616,13 @@ class TicketSettingsForm(SettingsForm):
|
||||
label=_("Download date"),
|
||||
help_text=_("Ticket download will be offered after this date."),
|
||||
required=True,
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'})
|
||||
widget=forms.DateTimeInput(attrs={'class': 'datetimepicker',
|
||||
'data-display-dependency': '#id_ticket_download'}),
|
||||
)
|
||||
ticket_download_addons = forms.BooleanField(
|
||||
label=_("Offer to download tickets separately for add-on products"),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}),
|
||||
)
|
||||
|
||||
def prepare_fields(self):
|
||||
@@ -586,6 +631,10 @@ class TicketSettingsForm(SettingsForm):
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
if isinstance(v, I18nFormField):
|
||||
v._required = v.one_required
|
||||
v.one_required = False
|
||||
v.widget.enabled_locales = self.locales
|
||||
|
||||
def clean(self):
|
||||
# required=True files should only be required if the feature is enabled
|
||||
|
||||
@@ -11,6 +11,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
|
||||
from pretix.base.models import (
|
||||
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn
|
||||
|
||||
|
||||
class CategoryForm(I18nModelForm):
|
||||
@@ -19,7 +20,8 @@ class CategoryForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'description'
|
||||
'description',
|
||||
'is_addon'
|
||||
]
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['category'].queryset = self.instance.event.categories.all()
|
||||
self.fields['copy_from'] = forms.ModelChoiceField(
|
||||
label=_("Copy product information"),
|
||||
queryset=self.event.items.all(),
|
||||
@@ -138,6 +141,7 @@ class ItemCreateForm(I18nModelForm):
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'name',
|
||||
'category',
|
||||
'admission',
|
||||
'default_price',
|
||||
'tax_rate',
|
||||
@@ -168,7 +172,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'require_voucher',
|
||||
'hide_without_voucher',
|
||||
'allow_cancel',
|
||||
'max_per_order'
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
]
|
||||
widgets = {
|
||||
'available_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
|
||||
@@ -206,4 +211,64 @@ class ItemVariationForm(I18nModelForm):
|
||||
'value',
|
||||
'active',
|
||||
'default_price',
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class ItemAddOnsFormSet(I18nFormSet):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.get('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _construct_form(self, i, **kwargs):
|
||||
kwargs['event'] = self.event
|
||||
return super()._construct_form(i, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
categories = set()
|
||||
for i in range(0, self.total_form_count()):
|
||||
form = self.forms[i]
|
||||
if self.can_delete:
|
||||
if self._should_delete_form(form):
|
||||
# This form is going to be deleted so any of its errors
|
||||
# should not cause the entire formset to be invalid.
|
||||
continue
|
||||
|
||||
if form.cleaned_data['addon_category'] in categories:
|
||||
raise ValidationError(_('You added the same add-on category twice'))
|
||||
|
||||
categories.add(form.cleaned_data['addon_category'])
|
||||
|
||||
@property
|
||||
def empty_form(self):
|
||||
self.is_valid()
|
||||
form = self.form(
|
||||
auto_id=self.auto_id,
|
||||
prefix=self.add_prefix('__prefix__'),
|
||||
empty_permitted=True,
|
||||
locales=self.locales,
|
||||
event=self.event
|
||||
)
|
||||
self.add_fields(form, None)
|
||||
return form
|
||||
|
||||
|
||||
class ItemAddOnForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['addon_category'].queryset = self.event.categories.all()
|
||||
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
localized_fields = '__all__'
|
||||
fields = [
|
||||
'addon_category',
|
||||
'min_count',
|
||||
'max_count',
|
||||
]
|
||||
help_texts = {
|
||||
'min_count': _('Be aware that setting a minimal number makes it impossible to buy this product if all '
|
||||
'available add-ons are sold out.')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.formats import localize
|
||||
@@ -118,3 +119,16 @@ class OrderContactForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['email']
|
||||
|
||||
|
||||
class OrderLocaleForm(forms.ModelForm):
|
||||
locale = forms.ChoiceField()
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['locale']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
locale_names = dict(settings.LANGUAGES)
|
||||
self.fields['locale'].choices = [(a, locale_names[a]) for a in self.instance.event.settings.locales]
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
from pretix.base.models import Organizer
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
|
||||
|
||||
class OrganizerForm(I18nModelForm):
|
||||
@@ -25,9 +26,42 @@ class OrganizerForm(I18nModelForm):
|
||||
|
||||
|
||||
class OrganizerUpdateForm(OrganizerForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.domain = kwargs.pop('domain', False)
|
||||
kwargs.setdefault('initial', {})
|
||||
self.instance = kwargs['instance']
|
||||
if self.domain and self.instance:
|
||||
initial_domain = self.instance.domains.first()
|
||||
if initial_domain:
|
||||
kwargs['initial'].setdefault('domain', initial_domain.domainname)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
if self.domain:
|
||||
self.fields['domain'] = forms.CharField(
|
||||
max_length=255,
|
||||
label=_('Custom domain'),
|
||||
required=False,
|
||||
help_text=_('You need to configure the custom domain in the webserver beforehand.')
|
||||
)
|
||||
|
||||
def clean_slug(self):
|
||||
return self.instance.slug
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit)
|
||||
|
||||
if self.domain:
|
||||
current_domain = instance.domains.first()
|
||||
if self.cleaned_data['domain']:
|
||||
if current_domain and current_domain.domainname != self.cleaned_data['domain']:
|
||||
current_domain.delete()
|
||||
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
|
||||
elif not current_domain:
|
||||
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
|
||||
elif current_domain:
|
||||
current_domain.delete()
|
||||
instance.get_cache().clear()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -76,6 +76,9 @@ class VoucherForm(I18nModelForm):
|
||||
else:
|
||||
self.instance.variation = None
|
||||
self.instance.quota = None
|
||||
|
||||
if self.instance.item.category and self.instance.item.category.is_addon:
|
||||
raise ValidationError(_('It is currently not possible to create vouchers for add-on products.'))
|
||||
else:
|
||||
self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event)
|
||||
self.instance.item = None
|
||||
|
||||
@@ -95,6 +95,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
|
||||
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
|
||||
'pretix.event.item.variation.changed': _('The variation "{value}" has been modified.'),
|
||||
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
|
||||
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
|
||||
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
|
||||
'pretix.event.quota.added': _('The quota has been added.'),
|
||||
'pretix.event.quota.deleted': _('The quota has been deleted.'),
|
||||
'pretix.event.quota.changed': _('The quota has been modified.'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
from pretix.base.signals import EventPluginSignal
|
||||
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
|
||||
|
||||
restriction_formset = EventPluginSignal(
|
||||
providing_args=["item"]
|
||||
@@ -47,11 +47,31 @@ nav_topbar = Signal(
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the top navigation bar.
|
||||
You will get the request as a keyword argument ``return``.
|
||||
You will get the request as a keyword argument ``request``.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You can also return
|
||||
a fontawesome icon name with the key ``icon``, it will be respected depending
|
||||
on the type of navigation. If set, on desktops only the ``icon`` will be shown.
|
||||
The ``title`` property can be used to set the alternative text.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
|
||||
This is no ``EventPluginSignal``, so you do not get the event in the ``sender`` argument
|
||||
and you may get the signal regardless of whether your plugin is active.
|
||||
"""
|
||||
|
||||
nav_global = Signal(
|
||||
providing_args=["request"]
|
||||
)
|
||||
"""
|
||||
This signal allows you to add additional views to the navigation bar when no event is
|
||||
selected. You will get the request as a keyword argument ``request``.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You can also return
|
||||
a fontawesome icon name with the key ``icon``, it will be respected depending
|
||||
on the type of navigation. You should also return an ``active`` key with a boolean
|
||||
set to ``True``, when this item should be marked as active.
|
||||
|
||||
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
|
||||
in pretix.
|
||||
@@ -123,14 +143,29 @@ quota as argument in the ``quota`` keyword argument.
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
organizer_edit_tabs = Signal(
|
||||
organizer_edit_tabs = DeprecatedSignal(
|
||||
providing_args=['organizer', 'request']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tabs on the detail page of an organizer. Receivers
|
||||
should return a tuple with the first item being the tab title and the second item
|
||||
being the content as HTML. The receivers get the ``organizer`` and the ``request`` as
|
||||
keyword arguments.
|
||||
|
||||
This is a regular django signal (no pretix event signal).
|
||||
Deprecated signal, no longer works. We just keep the definition so old plugins don't
|
||||
break the installation.
|
||||
"""
|
||||
|
||||
|
||||
nav_organizer = Signal(
|
||||
providing_args=['organizer', 'request']
|
||||
)
|
||||
"""
|
||||
This signal is sent out to include tab links on the detail page of an organizer.
|
||||
Receivers are expected to return a list of dictionaries. The dictionaries
|
||||
should contain at least the keys ``label`` and ``url``. You should also return
|
||||
an ``active`` key with a boolean set to ``True``, when this item should be marked
|
||||
as active.
|
||||
|
||||
If your linked view should stay in the tab-like context of this page, we recommend
|
||||
that you use ``pretix.control.views.organizer.OrganizerDetailViewMixin`` for your view
|
||||
and your tempalte inherits from ``pretixcontrol/organizers/base.html``.
|
||||
|
||||
This is a regular django signal (no pretix event signal). Receivers will be passed
|
||||
the keyword arguments ``organizer`` and ``request``.
|
||||
"""
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/mail.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/asyncdownload.js" %}"></script>
|
||||
{% endcompress %}
|
||||
@@ -78,7 +79,7 @@
|
||||
<ul class="nav navbar-nav navbar-top-links navbar-right">
|
||||
{% for nav in nav_topbar %}
|
||||
<li {% if nav.children %}class="dropdown"{% endif %}>
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<span class="fa fa-{{ nav.icon }}"></span>
|
||||
@@ -113,7 +114,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a href="{% url 'control:user.settings' %}">
|
||||
<a href="{% url 'control:user.settings' %}" title="{% trans "Account Settings" %}" >
|
||||
<i class="fa fa-user"></i> {{ request.user.get_full_name }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -167,6 +168,32 @@
|
||||
{% trans "Organizers" %}
|
||||
</a>
|
||||
</li>
|
||||
{% for nav in nav_global %}
|
||||
<li>
|
||||
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
|
||||
{% if nav.children %}class="has-children"{% endif %}>
|
||||
{% if nav.icon %}
|
||||
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
|
||||
{% endif %}
|
||||
{{ nav.label }}
|
||||
</a>
|
||||
{% if nav.children %}
|
||||
<a href="#" class="arrow">
|
||||
<span class="fa arrow"></span>
|
||||
</a>
|
||||
<ul class="nav nav-second-level">
|
||||
{% for item in nav.children %}
|
||||
<li>
|
||||
<a href="{{ item.url }}"
|
||||
{% if item.active %}class="active"{% endif %}>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -85,12 +85,15 @@
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-6 col-xs-12">
|
||||
{% if log.user %}
|
||||
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
|
||||
{% if log.user.is_superuser %}
|
||||
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
|
||||
data-toggle="tooltip" class="user-admin-icon"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ log.user.get_full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -32,12 +32,15 @@
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-6 col-xs-12">
|
||||
{% if log.user %}
|
||||
<span class="fa fa-user"></span> {{ log.user.get_full_name }}
|
||||
{% if log.user.is_superuser %}
|
||||
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
|
||||
data-toggle="tooltip" class="user-admin-icon"
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ log.user.get_full_name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-2 col-sm-12 col-xs-12">
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
|
||||
mail-preview-url="{% url "control:event.settings.mail.preview" event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
@@ -13,106 +14,26 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<div class="panel-group" id="questions_group">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_placed">
|
||||
<strong>{% trans "Placed order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_placed" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_placed layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_paid">
|
||||
<strong>{% trans "Paid order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_paid" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_paid layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_free">
|
||||
<strong>{% trans "Free order" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_free" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_free layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#resend_link">
|
||||
<strong>{% trans "Resend link" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="resend_link" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_resend_link layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_text_resend_all_links layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_changed">
|
||||
<strong>{% trans "Order changed" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_changed" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_order_changed layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#order_expirew">
|
||||
<strong>{% trans "Payment reminder" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="order_expirew" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_days_order_expire_warning layout="horizontal" %}
|
||||
{% bootstrap_field form.mail_text_order_expire_warning layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#waiting_list">
|
||||
<strong>{% trans "Waiting list notification" %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="waiting_list" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_field form.mail_text_waiting_list layout="horizontal" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed" %}
|
||||
|
||||
{% blocktrans asvar title_paid_order %}Paid order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_paid" title=title_paid_order items="mail_text_order_paid" %}
|
||||
|
||||
{% blocktrans asvar title_free_order %}Free order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_free" title=title_free_order items="mail_text_order_free" %}
|
||||
|
||||
{% blocktrans asvar title_resend_link %}Resend link{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="resend_link" title=title_resend_link items="mail_text_resend_link,mail_text_resend_all_links" %}
|
||||
|
||||
{% blocktrans asvar title_order_changed %}Order changed{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_changed" title=title_order_changed items="mail_text_order_changed" %}
|
||||
|
||||
{% blocktrans asvar title_payment_reminder %}Payment reminder{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_expirew" title=title_payment_reminder items="mail_days_order_expire_warning,mail_text_order_expire_warning" exclude="mail_days_order_expire_warning" %}
|
||||
|
||||
{% blocktrans asvar title_waiting_list_notification %}Waiting list notification{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="waiting_list" title=title_waiting_list_notification items="mail_text_waiting_list" %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load mail_settings_preview %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#{{ pid }}">
|
||||
<strong>{% trans title %}</strong>
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="{{ pid }}" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
{% with exclude|split as exclusion %}
|
||||
{% with items|split as item_list %}
|
||||
{% for item in item_list %}
|
||||
{% if item in exclusion %}
|
||||
{% with form|getattr:item as field %}
|
||||
{% bootstrap_field field layout="horizontal" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div id="{{ item }}_panel" class="preview-panel form-group" for="{{ item }}">
|
||||
{% with form|getattr:item as field %}
|
||||
<label class="col-md-3 control-label">{{ field.label }}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="tab-content">
|
||||
<div id="{{ item }}_edit" class="tab-pane fade in active">
|
||||
{% bootstrap_field field show_label=False form_group_class="" %}
|
||||
</div>
|
||||
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
|
||||
{% for l in request.event.settings.locales %}
|
||||
<pre lang="{{ l }}" for="{{ item }}" class="mail-preview"></pre>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="nav nav-pills pull-right">
|
||||
<li role="presentation" class="active">
|
||||
<a data-toggle="pill" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a data-toggle="pill" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,8 +72,8 @@
|
||||
<td>{{ add_form.can_view_orders }}</td>
|
||||
<td>{{ add_form.can_change_orders }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
<td>{{ add_form.can_view_vouchers }}</td>
|
||||
<td>{{ add_form.can_change_vouchers }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
{% bootstrap_field sform.max_items_per_order layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_names_asked layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_names_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked layout="horizontal" %}
|
||||
{% bootstrap_field sform.attendee_emails_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.ticket_download layout="horizontal" %}
|
||||
{% bootstrap_field form.ticket_download_date layout="horizontal" %}
|
||||
{% bootstrap_field form.ticket_download_addons layout="horizontal" %}
|
||||
{% for provider in providers %}
|
||||
<div class="panel panel-default ticketoutput-panel">
|
||||
<div class="panel-heading">
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
<p class="meta">
|
||||
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if log.user %}
|
||||
<br/><span class="fa fa-user"></span> {{ log.user.get_full_name }}
|
||||
{% if log.user.is_superuser %}
|
||||
<img src="{% static "pretixbase/img/pretix-icon-colored.svg" %}"
|
||||
data-toggle="tooltip" class="user-admin-icon"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
<span class="fa fa-id-card fa-danger fa-fw"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans "This change was performed by a pretix administrator." %}">
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ log.user.get_full_name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
|
||||
96
src/pretix/control/templates/pretixcontrol/item/addons.html
Normal file
96
src/pretix/control/templates/pretixcontrol/item/addons.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends "pretixcontrol/item/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load formset_tags %}
|
||||
{% block inside %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
With add-ons, you can specify products that can be bought as an addition to this product. For example, if
|
||||
you host a conference with a base conference ticket and a number of workshops, you could define the
|
||||
workshops as add-ons to the conference ticket. With this configuration, the workshops cannot be bought
|
||||
on their own but only in combination with a conference ticket. You can here specify categories of products
|
||||
that can be used as add-ons to this product. You can also specify the minimum and maximum number of
|
||||
add-ons of the given category that can or need to be chosen. The user can buy every add-on from the
|
||||
category at most once. If an add-on product has multiple variations, only one of them can be bought.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<form class="form-horizontal branches" method="post" action="">
|
||||
{% csrf_token %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for form in formset %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ form.id }}
|
||||
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<h3 class="panel-title">{% trans "Add-On" %}</h3>
|
||||
</div>
|
||||
<div class="col-sm-4 text-right">
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field form.max_count layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div class="panel panel-default" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ formset.empty_form.id }}
|
||||
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<h3 class="panel-title">{% trans "Add-On" %}</h3>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-xs btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.addon_category layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.min_count layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.max_count layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add a new add-on" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -4,20 +4,25 @@
|
||||
{% block content %}
|
||||
{% if object.id %}
|
||||
<h1>{% trans "Modify product:" %} {{ object.name }}</h1>
|
||||
{% if object.has_variations %}
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "event.item" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "General information" %}
|
||||
</a>
|
||||
</li>
|
||||
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Variations" %}
|
||||
{% if object.has_variations %}
|
||||
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Variations" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li {% if "event.item.addons" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url 'control:event.item.addons' organizer=request.event.organizer.slug event=request.event.slug item=object.id %}">
|
||||
{% trans "Add-Ons" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1>{% trans "Create product" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
@@ -26,12 +31,12 @@
|
||||
{% endif %}
|
||||
{% if object.id and not object.quotas.exists %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
{% blocktrans trimmed %}
|
||||
Please note that your product will <strong>not</strong> be available for sale until you have added your
|
||||
item to an existing or newly created quota.
|
||||
{% endblocktrans %}
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block inside %}
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.copy_from layout="horizontal" %}
|
||||
{% bootstrap_field form.has_variations layout="horizontal" %}
|
||||
{% bootstrap_field form.category layout="horizontal" %}
|
||||
{% bootstrap_field form.admission layout="horizontal" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
{% bootstrap_field form.available_from layout="horizontal" %}
|
||||
{% bootstrap_field form.available_until layout="horizontal" %}
|
||||
{% bootstrap_field form.max_per_order layout="horizontal" %}
|
||||
{% bootstrap_field form.min_per_order layout="horizontal" %}
|
||||
{% bootstrap_field form.require_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.hide_without_voucher layout="horizontal" %}
|
||||
{% bootstrap_field form.allow_cancel layout="horizontal" %}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.active layout='horizontal' %}
|
||||
{% bootstrap_field form.default_price layout='horizontal' %}
|
||||
{% bootstrap_field form.description layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -69,6 +70,7 @@
|
||||
<div class="panel-body form-horizontal">
|
||||
{% bootstrap_field formset.empty_form.active layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.default_price layout='horizontal' %}
|
||||
{% bootstrap_field formset.empty_form.description layout='horizontal' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.description layout="horizontal" %}
|
||||
{% bootstrap_field form.is_addon layout="horizontal" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
{% if category %}
|
||||
|
||||
@@ -48,6 +48,13 @@
|
||||
{% if position.variation %}
|
||||
– {{ position.variation }}
|
||||
{% endif %}
|
||||
{% if position.addon_to %}
|
||||
– <em>
|
||||
{% blocktrans trimmed with posid=position.addon_to.positionid %}
|
||||
Add-On to position #{{ posid }}
|
||||
{% endblocktrans %}
|
||||
</em>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@@ -89,6 +96,11 @@
|
||||
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
|
||||
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
|
||||
{% trans "Remove from order" %}
|
||||
{% if position.addons.exists %}
|
||||
<em class="text-danger">
|
||||
{% trans "Removing this position will also remove all add-ons to this position." %}
|
||||
</em>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% trans "Change locale information" %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Change locale information" %}
|
||||
</h1>
|
||||
<p>
|
||||
This language will be used whenever emails are sent to the users.
|
||||
</p>
|
||||
|
||||
<form method="post" class="form-horizontal" href="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="status" value="c" />
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
<div class="form-group submit-group">
|
||||
<a class="btn btn-default btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button class="btn btn-primary btn-save btn-lg" type="submit">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -73,6 +73,13 @@
|
||||
<dd>{{ order.code }}</dd>
|
||||
<dt>{% trans "Order date" %}</dt>
|
||||
<dd>{{ order.datetime }}</dd>
|
||||
<dt>{% trans "Order locale" %}</dt>
|
||||
<dd>
|
||||
{{ display_locale }}
|
||||
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
|
||||
<span class="fa fa-edit"></span>
|
||||
</a>
|
||||
</dd>
|
||||
{% if order.status == "p" %}
|
||||
<dt>{% trans "Payment date" %}</dt>
|
||||
<dd>{{ order.payment_date }}</dd>
|
||||
@@ -159,7 +166,11 @@
|
||||
{% for line in items.positions %}
|
||||
<div class="row-fluid product-row">
|
||||
<div class="col-md-9 col-xs-6">
|
||||
#{{ line.positionid }} –
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
{% else %}
|
||||
#{{ line.positionid }} –
|
||||
{% endif %}
|
||||
<strong>{{ line.item.name }}</strong>
|
||||
{% if line.variation %}
|
||||
– {{ line.variation }}
|
||||
@@ -180,6 +191,11 @@
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_emails_asked %}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>{% if line.attendee_email %}{{ line.attendee_email }}{% else %}
|
||||
<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
<h3 class="panel-title">{{ e.verbose_name }}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||
data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
|
||||
{% bootstrap_form e.form layout='horizontal' %}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</h1>
|
||||
<ul class="nav nav-pills">
|
||||
<li {% if "organizer" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer" organizer=organizer.slug %}">
|
||||
{% trans "Events" %}
|
||||
</a>
|
||||
</li>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<li {% if "organizer.teams" == url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer.teams" organizer=organizer.slug %}">
|
||||
{% trans "Permissions" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for nav in nav_organizer %}
|
||||
<li {% if nav.active %}class="active"{% endif %}>
|
||||
<a href="{{ nav.url }}">
|
||||
{{ nav.label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% block inner %}
|
||||
{% endblock %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,159 +1,34 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% blocktrans with name=organizer.name %}Organizer: {{ name }}{% endblocktrans %}
|
||||
<a href="{% url "control:organizer.edit" organizer=organizer.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</h1>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active">
|
||||
<a href="#tab-events" data-toggle="tab">{% trans "Events" %}</a>
|
||||
</li>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<li>
|
||||
<a href="#tab-permissions" data-toggle="tab">{% trans "Team" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for title, content in tabs %}
|
||||
<li>
|
||||
<a href="#tab-{{ forloop.counter }}" data-toggle="tab">
|
||||
{{ title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content organizer-tabs">
|
||||
<div class="tab-pane active" id="tab-events">
|
||||
<div class="tab-inner">
|
||||
{% if events|length == 0 %}
|
||||
<p>
|
||||
<em>{% trans "You currently do not have access to any events." %}</em>
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Event name" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.get_date_from_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<a href="{% url "control:events.add" %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new event" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if request.orgaperm.can_change_permissions %}
|
||||
<div class="tab-pane" id="tab-permissions">
|
||||
<div class="tab-inner">
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can use the following list to control who can create new events in the name of
|
||||
this organizer and who can add more people to this list. This does <strong>not</strong>
|
||||
control who has access to a particular event. You can control the access to an
|
||||
event in the "Permissions" section of the event's settings. A user does not need to
|
||||
be on the list here to get access to an event.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Everyone on this list can control the organizer settings on this page." %}
|
||||
</p>
|
||||
|
||||
{% bootstrap_formset_errors formset %}
|
||||
{{ formset.management_form }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Create events" %}</th>
|
||||
<th>{% trans "Change permissions" %}</th>
|
||||
<th>{% trans "Delete" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.id }}
|
||||
{% if form.instance.user %}
|
||||
{{ form.instance.user }}
|
||||
{% else %}
|
||||
{{ form.instance.invite_email }}
|
||||
<span class="fa fa-envelope-o" data-toggle="tooltip"
|
||||
title="{% trans "invited, pending response" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ form.can_create_events }}</td>
|
||||
<td>{{ form.can_change_permissions }}</td>
|
||||
<td>{{ form.DELETE }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<strong>{% trans "Adding a new user" %}</strong><br>
|
||||
{% blocktrans trimmed %}
|
||||
To add a new user, you can enter their email address here. If they
|
||||
already have a pretix account, they will immediately be added to the team.
|
||||
Otherwise, they will be sent an email with an invitation.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="row-fluid">
|
||||
<div class="col-sm-12">
|
||||
{% bootstrap_field add_form.user layout='inline' %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ add_form.can_create_events }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for title, content in tabs %}
|
||||
<div class="tab-pane" id="tab-{{ forloop.counter }}">
|
||||
<div class="tab-inner">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% block inner %}
|
||||
{% if events|length == 0 %}
|
||||
<p>
|
||||
<em>{% trans "You currently do not have access to any events." %}</em>
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Event name" %}</th>
|
||||
<th>{% trans "Start date" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in events %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong>
|
||||
</td>
|
||||
<td>{{ e.get_date_from_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<a href="{% url "control:events.add" %}" class="btn btn-default">
|
||||
<span class="fa fa-plus"></span>
|
||||
{% trans "Create a new event" %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="horizontal" %}
|
||||
{% bootstrap_field form.slug layout="horizontal" %}
|
||||
{% if form.domain %}
|
||||
{% bootstrap_field form.domain layout="horizontal" %}
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<form action="" method="post" class="form-horizontal form-permissions">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can use the following list to control who can create new events in the name of
|
||||
this organizer and who can add more people to this list. This does <strong>not</strong>
|
||||
control who has access to a particular event. You can control the access to an
|
||||
event in the "Permissions" section of the event's settings. A user does not need to
|
||||
be on the list here to get access to an event.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Everyone on this list can control the organizer settings on this page." %}
|
||||
</p>
|
||||
|
||||
{% bootstrap_formset_errors formset %}
|
||||
{{ formset.management_form }}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Create events" %}</th>
|
||||
<th>{% trans "Change permissions" %}</th>
|
||||
<th>{% trans "Delete" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ form.id }}
|
||||
{% if form.instance.user %}
|
||||
{{ form.instance.user }}
|
||||
{% else %}
|
||||
{{ form.instance.invite_email }}
|
||||
<span class="fa fa-envelope-o" data-toggle="tooltip"
|
||||
title="{% trans "invited, pending response" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ form.can_create_events }}</td>
|
||||
<td>{{ form.can_change_permissions }}</td>
|
||||
<td>{{ form.DELETE }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<strong>{% trans "Adding a new user" %}</strong><br>
|
||||
{% blocktrans trimmed %}
|
||||
To add a new user, you can enter their email address here. If they
|
||||
already have a pretix account, they will immediately be added to the team.
|
||||
Otherwise, they will be sent an email with an invitation.
|
||||
{% endblocktrans %}
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="row-fluid">
|
||||
<div class="col-sm-12">
|
||||
{% bootstrap_field add_form.user layout='inline' %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ add_form.can_create_events }}</td>
|
||||
<td>{{ add_form.can_change_permissions }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
13
src/pretix/control/templatetags/mail_settings_preview.py
Normal file
13
src/pretix/control/templatetags/mail_settings_preview.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(name='split', delimiter=",")
|
||||
def split(value, delimiter=","):
|
||||
return value.split(delimiter)
|
||||
|
||||
|
||||
@register.filter(name="getattr")
|
||||
def get_attribute(value, key):
|
||||
return value[key]
|
||||
@@ -35,6 +35,7 @@ urlpatterns = [
|
||||
url(r'^organizers/add$', organizer.OrganizerCreate.as_view(), name='organizers.add'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.OrganizerTeamView.as_view(), name='organizer.teams'),
|
||||
url(r'^events/$', main.EventList.as_view(), name='events'),
|
||||
url(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
|
||||
url(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
|
||||
@@ -52,6 +53,7 @@ urlpatterns = [
|
||||
url(r'^settings/tickets/preview/(?P<output>[^/]+)$', event.TicketSettingsPreview.as_view(),
|
||||
name='event.settings.tickets.preview'),
|
||||
url(r'^settings/email$', event.MailSettings.as_view(), name='event.settings.mail'),
|
||||
url(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
|
||||
url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
|
||||
url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
|
||||
url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
|
||||
@@ -60,6 +62,8 @@ urlpatterns = [
|
||||
url(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
|
||||
url(r'^items/(?P<item>\d+)/variations$', item.ItemVariations.as_view(),
|
||||
name='event.item.variations'),
|
||||
url(r'^items/(?P<item>\d+)/addons', item.ItemAddOns.as_view(),
|
||||
name='event.item.addons'),
|
||||
url(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
|
||||
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
|
||||
url(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
|
||||
@@ -111,6 +115,8 @@ urlpatterns = [
|
||||
name='event.order.extend'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/contact$', orders.OrderContactChange.as_view(),
|
||||
name='event.order.contact'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/locale', orders.OrderLocaleChange.as_view(),
|
||||
name='event.order.locale'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/comment$', orders.OrderComment.as_view(),
|
||||
name='event.order.comment'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/change$', orders.OrderChange.as_view(),
|
||||
@@ -120,6 +126,7 @@ urlpatterns = [
|
||||
name='event.invoice.download'),
|
||||
url(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
|
||||
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
url(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
|
||||
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
|
||||
url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'),
|
||||
url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'),
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files import File
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import transaction
|
||||
from django.forms import modelformset_factory
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import FormView, ListView
|
||||
from django.views.generic.base import TemplateView, View
|
||||
@@ -400,6 +406,112 @@ class MailSettings(EventSettingsFormView):
|
||||
return self.get(request)
|
||||
|
||||
|
||||
class MailSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_change_settings'
|
||||
|
||||
# return the origin text if key is missing in dict
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
@staticmethod
|
||||
def generate_order_fullname(slug, code):
|
||||
return '{event}-{code}'.format(event=slug.upper(), code=code)
|
||||
|
||||
# create data which depend on locale
|
||||
def localized_data(self):
|
||||
return {
|
||||
'date': date_format(now() + timedelta(days=7), 'SHORT_DATE_FORMAT'),
|
||||
'expire_date': date_format(now() + timedelta(days=15), 'SHORT_DATE_FORMAT'),
|
||||
'payment_info': _('{} {} has been transferred to account <9999-9999-9999-9999> at {}').format(
|
||||
42.23, self.request.event.currency, date_format(now(), 'SHORT_DATETIME_FORMAT'))
|
||||
}
|
||||
|
||||
# create index-language mapping
|
||||
@cached_property
|
||||
def supported_locale(self):
|
||||
locales = {}
|
||||
for idx, val in enumerate(settings.LANGUAGES):
|
||||
if val[0] in self.request.event.settings.locales:
|
||||
locales[str(idx)] = val[0]
|
||||
return locales
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return {
|
||||
'mail_text_order_placed': ['total', 'currency', 'date', 'invoice_company',
|
||||
'event', 'paymentinfo', 'url', 'invoice_name'],
|
||||
'mail_text_order_paid': ['event', 'url', 'invoice_name', 'invoice_company', 'payment_info'],
|
||||
'mail_text_order_free': ['event', 'url', 'invoice_name', 'invoice_company'],
|
||||
'mail_text_resend_link': ['event', 'url', 'invoice_name', 'invoice_company'],
|
||||
'mail_text_resend_all_links': ['event', 'orders'],
|
||||
'mail_text_order_changed': ['event', 'url', 'invoice_name', 'invoice_company'],
|
||||
'mail_text_order_expire_warning': ['event', 'url', 'expire_date', 'invoice_name', 'invoice_company'],
|
||||
'mail_text_waiting_list': ['event', 'url', 'product', 'hours', 'code']
|
||||
}
|
||||
|
||||
@cached_property
|
||||
def base_data(self):
|
||||
user_orders = [
|
||||
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
|
||||
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
|
||||
]
|
||||
orders = [' - {} - {}'.format(self.generate_order_fullname(self.request.event.slug, order['code']),
|
||||
self.generate_order_url(order['code'], order['secret']))
|
||||
for order in user_orders]
|
||||
return {
|
||||
'event': self.request.event.name,
|
||||
'total': 42.23,
|
||||
'currency': self.request.event.currency,
|
||||
'url': self.generate_order_url(user_orders[0]['code'], user_orders[0]['secret']),
|
||||
'orders': '\n'.join(orders),
|
||||
'hours': self.request.event.settings.waiting_list_hours,
|
||||
'product': _('Sample Admission Ticket'),
|
||||
'code': '68CYU2H6ZTP3WLK5',
|
||||
'invoice_name': _('John Doe'),
|
||||
'invoice_company': _('Sample Corporation'),
|
||||
'paymentinfo': _('Please transfer money to this bank account: 9999-9999-9999-9999')
|
||||
}
|
||||
|
||||
def generate_order_url(self, code, secret):
|
||||
return build_absolute_uri('presale:event.order', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'order': code,
|
||||
'secret': secret
|
||||
})
|
||||
|
||||
# get all supported placeholders with dummy values
|
||||
def placeholders(self, item):
|
||||
supported = {}
|
||||
local_data = self.localized_data()
|
||||
for key in self.items.get(item):
|
||||
supported[key] = self.base_data.get(key) if key in self.base_data else local_data.get(key)
|
||||
return self.SafeDict(supported)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
preview_item = request.POST.get('item', '')
|
||||
if preview_item not in self.items:
|
||||
return HttpResponseBadRequest(_('invalid item'))
|
||||
|
||||
regex = r"^" + re.escape(preview_item) + r"_(?P<idx>[\d+])$"
|
||||
msgs = {}
|
||||
for k, v in request.POST.items():
|
||||
# only accept allowed fields
|
||||
matched = re.search(regex, k)
|
||||
if matched is not None:
|
||||
idx = matched.group('idx')
|
||||
if idx in self.supported_locale:
|
||||
with translation.override(self.supported_locale[idx]):
|
||||
msgs[self.supported_locale[idx]] = v.format_map(self.placeholders(preview_item))
|
||||
|
||||
return JsonResponse({
|
||||
'item': preview_item,
|
||||
'msgs': msgs
|
||||
})
|
||||
|
||||
|
||||
class TicketSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_change_settings'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.core.urlresolvers import resolve, reverse
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F, Q
|
||||
from django.db.models import Count, F, Max, Q
|
||||
from django.forms.models import ModelMultipleChoiceField, inlineformset_factory
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
@@ -21,9 +21,11 @@ from pretix.base.models import (
|
||||
CachedTicket, Item, ItemCategory, ItemVariation, Order, Question,
|
||||
QuestionAnswer, QuestionOption, Quota, Voucher,
|
||||
)
|
||||
from pretix.base.models.items import ItemAddOn
|
||||
from pretix.control.forms.item import (
|
||||
CategoryForm, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
|
||||
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
|
||||
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm,
|
||||
ItemUpdateForm, ItemVariationForm, ItemVariationsFormSet, QuestionForm,
|
||||
QuestionOptionForm, QuotaForm,
|
||||
)
|
||||
from pretix.control.permissions import (
|
||||
EventPermissionRequiredMixin, event_permission_required,
|
||||
@@ -70,7 +72,7 @@ def item_move(request, item, up=True):
|
||||
if item.position != i:
|
||||
item.position = i
|
||||
item.save()
|
||||
messages.success(request, _('The order of items as been updated.'))
|
||||
messages.success(request, _('The order of items has been updated.'))
|
||||
|
||||
|
||||
@event_permission_required("can_change_items")
|
||||
@@ -791,7 +793,6 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
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
|
||||
@@ -799,6 +800,10 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
|
||||
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
|
||||
form.instance.min_per_order = form.cleaned_data['copy_from'].min_per_order
|
||||
form.instance.max_per_order = form.cleaned_data['copy_from'].max_per_order
|
||||
|
||||
form.instance.position = (self.request.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
|
||||
|
||||
ret = super().form_valid(form)
|
||||
form.instance.log_action('pretix.event.item.added', user=self.request.user, data={
|
||||
@@ -927,6 +932,89 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView
|
||||
return context
|
||||
|
||||
|
||||
class ItemAddOns(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_change_items'
|
||||
template_name = 'pretixcontrol/item/addons.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.item = None
|
||||
|
||||
@cached_property
|
||||
def formset(self):
|
||||
formsetclass = inlineformset_factory(
|
||||
Item, ItemAddOn,
|
||||
form=ItemAddOnForm, formset=ItemAddOnsFormSet,
|
||||
can_order=True, can_delete=True, extra=0
|
||||
)
|
||||
return formsetclass(self.request.POST if self.request.method == "POST" else None,
|
||||
queryset=ItemAddOn.objects.filter(base_item=self.get_object()),
|
||||
event=self.request.event)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
if self.formset.is_valid():
|
||||
for form in self.formset.deleted_forms:
|
||||
if not form.instance.pk:
|
||||
continue
|
||||
self.get_object().log_action(
|
||||
'pretix.event.item.addons.removed', user=self.request.user, data={
|
||||
'category': form.instance.addon_category.pk
|
||||
}
|
||||
)
|
||||
form.instance.delete()
|
||||
form.instance.pk = None
|
||||
|
||||
forms = self.formset.ordered_forms + [
|
||||
ef for ef in self.formset.extra_forms
|
||||
if ef not in self.formset.ordered_forms and ef not in self.formset.deleted_forms
|
||||
]
|
||||
for i, form in enumerate(forms):
|
||||
form.instance.base_item = self.get_object()
|
||||
form.instance.position = i
|
||||
created = not form.instance.pk
|
||||
form.save()
|
||||
if form.has_changed():
|
||||
change_data = {k: form.cleaned_data.get(k) for k in form.changed_data}
|
||||
change_data['id'] = form.instance.pk
|
||||
self.get_object().log_action(
|
||||
'pretix.event.item.addons.changed' if not created else
|
||||
'pretix.event.item.addons.added',
|
||||
user=self.request.user, data=change_data
|
||||
)
|
||||
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return redirect(self.get_success_url())
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if self.get_object().category and self.get_object().category.is_addon:
|
||||
messages.error(self.request, _('You cannot add addons to a product that is only available as an add-on '
|
||||
'itself.'))
|
||||
return redirect(self.get_previous_url())
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_previous_url(self) -> str:
|
||||
return reverse('control:event.item', kwargs={
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
'item': self.get_object().id,
|
||||
})
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:event.item.addons', kwargs={
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug,
|
||||
'item': self.get_object().id,
|
||||
})
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['formset'] = self.formset
|
||||
return context
|
||||
|
||||
|
||||
class ItemDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
model = Item
|
||||
template_name = 'pretixcontrol/item/delete.html'
|
||||
|
||||
@@ -22,13 +22,13 @@ class EventList(ListView):
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_superuser:
|
||||
return Event.objects.all().select_related("organizer").prefetch_related(
|
||||
"setting_objects", "organizer__setting_objects"
|
||||
"_settings_objects", "organizer___settings_objects"
|
||||
)
|
||||
else:
|
||||
return Event.objects.filter(
|
||||
permitted__id__exact=self.request.user.pk
|
||||
).select_related("organizer").prefetch_related(
|
||||
"setting_objects", "organizer__setting_objects"
|
||||
"_settings_objects", "organizer___settings_objects"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Q
|
||||
@@ -29,8 +30,9 @@ from pretix.base.services.stats import order_overview
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_payment_providers,
|
||||
)
|
||||
from pretix.base.views.async import AsyncAction
|
||||
from pretix.control.forms.orders import (
|
||||
CommentForm, ExporterForm, ExtendForm, OrderContactForm,
|
||||
CommentForm, ExporterForm, ExtendForm, OrderContactForm, OrderLocaleForm,
|
||||
OrderPositionChangeForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -52,6 +54,7 @@ class OrderList(EventPermissionRequiredMixin, ListView):
|
||||
u = self.request.GET.get("user", "")
|
||||
qs = qs.filter(
|
||||
Q(email__icontains=u) | Q(positions__attendee_name__icontains=u)
|
||||
| Q(positions__attendee_email__icontains=u)
|
||||
)
|
||||
if self.request.GET.get("status", "") != "":
|
||||
s = self.request.GET.get("status", "")
|
||||
@@ -152,6 +155,7 @@ class OrderDetail(OrderView):
|
||||
ctx['payment'] = self.payment_provider.order_control_render(self.request, self.object)
|
||||
ctx['invoices'] = list(self.order.invoices.all().select_related('event'))
|
||||
ctx['comment_form'] = CommentForm(initial={'comment': self.order.comment})
|
||||
ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale]
|
||||
return ctx
|
||||
|
||||
def get_items(self):
|
||||
@@ -169,6 +173,7 @@ class OrderDetail(OrderView):
|
||||
for p in cartpos:
|
||||
p.has_questions = (
|
||||
(p.item.admission and self.request.event.settings.attendee_names_asked) or
|
||||
(p.item.admission and self.request.event.settings.attendee_emails_asked) or
|
||||
p.item.questions.all()
|
||||
)
|
||||
p.cache_answers()
|
||||
@@ -552,6 +557,40 @@ class OrderContactChange(OrderView):
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
|
||||
class OrderLocaleChange(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
template_name = 'pretixcontrol/order/change_locale.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['form'] = self.form
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def form(self):
|
||||
return OrderLocaleForm(
|
||||
instance=self.order,
|
||||
data=self.request.POST if self.request.method == "POST" else None
|
||||
)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
old_locale = self.order.locale
|
||||
if self.form.is_valid():
|
||||
self.order.log_action(
|
||||
'pretix.event.order.locale.changed',
|
||||
data={
|
||||
'old_locale': old_locale,
|
||||
'new_locale': self.form.cleaned_data['locale'],
|
||||
},
|
||||
user=self.request.user,
|
||||
)
|
||||
|
||||
self.form.save()
|
||||
messages.success(self.request, _('The order has been changed.'))
|
||||
return redirect(self.get_order_url())
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
|
||||
class OverView(EventPermissionRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/orders/overview.html'
|
||||
permission = 'can_view_orders'
|
||||
@@ -586,9 +625,7 @@ class OrderGo(EventPermissionRequiredMixin, View):
|
||||
return redirect('control:event.orders', event=request.event.slug, organizer=request.event.organizer.slug)
|
||||
|
||||
|
||||
class ExportView(EventPermissionRequiredMixin, TemplateView):
|
||||
permission = 'can_view_orders'
|
||||
template_name = 'pretixcontrol/orders/export.html'
|
||||
class ExportMixin:
|
||||
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
@@ -604,10 +641,22 @@ class ExportView(EventPermissionRequiredMixin, TemplateView):
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['exporters'] = self.exporters
|
||||
return ctx
|
||||
|
||||
class ExportDoView(EventPermissionRequiredMixin, ExportMixin, AsyncAction, View):
|
||||
permission = 'can_view_orders'
|
||||
task = export
|
||||
|
||||
def get_success_message(self, value):
|
||||
return None
|
||||
|
||||
def get_success_url(self, value):
|
||||
return reverse('cachedfile.download', kwargs={'id': str(value)})
|
||||
|
||||
def get_error_url(self):
|
||||
return reverse('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
|
||||
@cached_property
|
||||
def exporter(self):
|
||||
@@ -615,13 +664,14 @@ class ExportView(EventPermissionRequiredMixin, TemplateView):
|
||||
if ex.identifier == self.request.POST.get("exporter"):
|
||||
return ex
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.exporter:
|
||||
messages.error(self.request, _('The selected exporter was not found.'))
|
||||
return redirect('control:event.orders.export', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug
|
||||
})
|
||||
|
||||
if not self.exporter.form.is_valid():
|
||||
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
|
||||
return self.get(*args, **kwargs)
|
||||
@@ -630,6 +680,14 @@ class ExportView(EventPermissionRequiredMixin, TemplateView):
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(days=3)
|
||||
cf.save()
|
||||
export.apply_async(args=(self.request.event.id, str(cf.id), self.exporter.identifier,
|
||||
self.exporter.form.cleaned_data))
|
||||
return redirect(reverse('cachedfile.download', kwargs={'id': str(cf.id)}))
|
||||
return self.do(self.request.event.id, str(cf.id), self.exporter.identifier, self.exporter.form.cleaned_data)
|
||||
|
||||
|
||||
class ExportView(EventPermissionRequiredMixin, ExportMixin, TemplateView):
|
||||
permission = 'can_view_orders'
|
||||
template_name = 'pretixcontrol/orders/export.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['exporters'] = self.exporters
|
||||
return ctx
|
||||
|
||||
@@ -14,7 +14,7 @@ from pretix.base.models import Organizer, OrganizerPermission, User
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.control.forms.organizer import OrganizerForm, OrganizerUpdateForm
|
||||
from pretix.control.permissions import OrganizerPermissionRequiredMixin
|
||||
from pretix.control.signals import organizer_edit_tabs
|
||||
from pretix.control.signals import nav_organizer
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
|
||||
@@ -45,7 +45,23 @@ class OrganizerPermissionCreateForm(OrganizerPermissionForm):
|
||||
user = forms.EmailField(required=False, label=_('User'))
|
||||
|
||||
|
||||
class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
class OrganizerDetailViewMixin:
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['nav_organizer'] = []
|
||||
ctx['organizer'] = self.request.organizer
|
||||
|
||||
for recv, retv in nav_organizer.send(sender=self.request.organizer, request=self.request,
|
||||
organizer=self.request.organizer):
|
||||
ctx['nav_organizer'] += retv
|
||||
return ctx
|
||||
|
||||
def get_object(self, queryset=None) -> Organizer:
|
||||
return self.request.organizer
|
||||
|
||||
|
||||
class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||
model = Organizer
|
||||
template_name = 'pretixcontrol/organizers/detail.html'
|
||||
permission = None
|
||||
@@ -54,6 +70,18 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
def get_object(self, queryset=None) -> Organizer:
|
||||
return self.request.organizer
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['events'] = self.request.organizer.events.all()
|
||||
return ctx
|
||||
|
||||
|
||||
class OrganizerTeamView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||
model = Organizer
|
||||
template_name = 'pretixcontrol/organizers/teams.html'
|
||||
permission = 'can_change_permissions'
|
||||
context_object_name = 'organizer'
|
||||
|
||||
@cached_property
|
||||
def formset(self):
|
||||
fs = modelformset_factory(
|
||||
@@ -86,13 +114,6 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['formset'] = self.formset
|
||||
ctx['add_form'] = self.add_form
|
||||
ctx['events'] = self.request.organizer.events.all()
|
||||
ctx['tabs'] = []
|
||||
|
||||
for recv, retv in organizer_edit_tabs.send(sender=self.request.organizer, request=self.request,
|
||||
organizer=self.request.organizer):
|
||||
ctx['tabs'].append(retv)
|
||||
|
||||
return ctx
|
||||
|
||||
def _send_invite(self, instance):
|
||||
@@ -116,12 +137,6 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, *args, **kwargs):
|
||||
if not self.request.orgaperm.can_change_permissions:
|
||||
raise PermissionDenied(_("You have no permission to do this."))
|
||||
|
||||
if 'formset-TOTAL_FORMS' not in self.request.POST:
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
if self.formset.is_valid() and self.add_form.is_valid():
|
||||
if self.add_form.has_changed():
|
||||
logdata = {
|
||||
@@ -186,7 +201,7 @@ class OrganizerDetail(OrganizerPermissionRequiredMixin, DetailView):
|
||||
return self.get(*args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:organizer', kwargs={
|
||||
return reverse('control:organizer.teams', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
@@ -205,6 +220,12 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self.request.user.is_superuser:
|
||||
kwargs['domain'] = True
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:organizer.edit', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-04-01 13:41+0000\n"
|
||||
"PO-Revision-Date: 2017-01-01 20:40+0100\n"
|
||||
"POT-Creation-Date: 2017-05-02 08:59+0000\n"
|
||||
"PO-Revision-Date: 2017-04-17 17:10+0200\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
@@ -46,9 +46,9 @@ msgstr "Gesamtumsatz"
|
||||
msgid "Contacting Stripe …"
|
||||
msgstr "Kontaktiere Stripe …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:64
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:28
|
||||
#: pretix/static/pretixbase/js/asynctask.js:42
|
||||
#: pretix/static/pretixbase/js/asynctask.js:94
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
@@ -58,19 +58,19 @@ msgstr ""
|
||||
"dies länger als zwei Minuten dauert, kontaktieren Sie uns bitte oder gehen "
|
||||
"Sie in Ihrem Browser einen Schritt zurück und versuchen es erneut."
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:40
|
||||
#: pretix/static/pretixbase/js/asynctask.js:43
|
||||
#: pretix/static/pretixbase/js/asynctask.js:85
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:41
|
||||
#: pretix/static/pretixbase/js/asynctask.js:62
|
||||
#: pretix/static/pretixbase/js/asynctask.js:116
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:53
|
||||
#: pretix/static/pretixbase/js/asynctask.js:103
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:137
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten Ihre Anfrage …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:104
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:55
|
||||
#: pretix/static/pretixbase/js/asynctask.js:138
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
@@ -80,7 +80,16 @@ msgstr ""
|
||||
"dauert, prüfen Sie bitte Ihre Internetverbindung. Danach können Sie diese "
|
||||
"Seite neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:46
|
||||
#: pretix/static/pretixbase/js/asynctask.js:38
|
||||
#: pretix/static/pretixbase/js/asynctask.js:90
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. "
|
||||
"Depending on the size of your event, this might take up to a few minutes."
|
||||
msgstr ""
|
||||
"Ihre Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Je nach "
|
||||
"Größe der Veranstaltung kann dies einige Minuten dauern."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:65
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
@@ -88,18 +97,19 @@ msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:76
|
||||
#: pretix/static/pretixbase/js/asynctask.js:107
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:20
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:88
|
||||
#: pretix/static/pretixbase/js/asynctask.js:119
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuchen Sie es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:140
|
||||
#: pretix/static/pretixbase/js/asynctask.js:174
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
@@ -112,6 +122,25 @@ msgstr "Kopiert!"
|
||||
msgid "Press Ctrl-C to copy!"
|
||||
msgstr "Drücken Sie Strg+C zum Kopieren!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:18
|
||||
msgid "An error has occurred."
|
||||
msgstr "Ein Fehler ist aufgetreten."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:23
|
||||
msgid "An error of type {code} occurred."
|
||||
msgstr "Ein Fehler vom Typ {code} ist aufgetreten."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:25
|
||||
msgid ""
|
||||
"We currently cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:52
|
||||
msgid "Generating messages …"
|
||||
msgstr "Generiere Nachrichten…"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:43
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-04-01 13:41+0000\n"
|
||||
"PO-Revision-Date: 2017-01-18 09:42+0100\n"
|
||||
"POT-Creation-Date: 2017-05-02 08:59+0000\n"
|
||||
"PO-Revision-Date: 2017-04-17 17:09+0200\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
@@ -46,9 +46,9 @@ msgstr "Gesamtumsatz"
|
||||
msgid "Contacting Stripe …"
|
||||
msgstr "Kontaktiere Stripe …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:27
|
||||
#: pretix/static/pretixbase/js/asynctask.js:64
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:28
|
||||
#: pretix/static/pretixbase/js/asynctask.js:42
|
||||
#: pretix/static/pretixbase/js/asynctask.js:94
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
@@ -58,19 +58,19 @@ msgstr ""
|
||||
"dies länger als zwei Minuten dauert, kontaktiere uns bitte oder gehe in "
|
||||
"deinem Browser einen Schritt zurück und versuche es erneut."
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:40
|
||||
#: pretix/static/pretixbase/js/asynctask.js:43
|
||||
#: pretix/static/pretixbase/js/asynctask.js:85
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:41
|
||||
#: pretix/static/pretixbase/js/asynctask.js:62
|
||||
#: pretix/static/pretixbase/js/asynctask.js:116
|
||||
msgid "An error of type {code} occured."
|
||||
msgstr "Ein Fehler ist aufgetreten. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:53
|
||||
#: pretix/static/pretixbase/js/asynctask.js:103
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:137
|
||||
msgid "We are processing your request …"
|
||||
msgstr "Wir verarbeiten deine Anfrage …"
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:104
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:55
|
||||
#: pretix/static/pretixbase/js/asynctask.js:138
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
@@ -80,7 +80,16 @@ msgstr ""
|
||||
"dauert, prüfe bitte deine Internetverbindung. Danach kannst du diese Seite "
|
||||
"neu laden und es erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:46
|
||||
#: pretix/static/pretixbase/js/asynctask.js:38
|
||||
#: pretix/static/pretixbase/js/asynctask.js:90
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. "
|
||||
"Depending on the size of your event, this might take up to a few minutes."
|
||||
msgstr ""
|
||||
"Deine Anfrage ist auf dem Server angekommen und wird nun verarbeitet. Je "
|
||||
"nach Größe der Veranstaltung kann dies einige Minuten dauern."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:65
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
@@ -88,18 +97,19 @@ msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen, versuchen es aber weiter. "
|
||||
"Letzter Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:76
|
||||
#: pretix/static/pretixbase/js/asynctask.js:107
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:20
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr "Diese Anfrage hat zu lange gedauert. Bitte erneut versuchen."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:88
|
||||
#: pretix/static/pretixbase/js/asynctask.js:119
|
||||
msgid ""
|
||||
"We currenctly cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:140
|
||||
#: pretix/static/pretixbase/js/asynctask.js:174
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
msgid "Close message"
|
||||
msgstr "Schließen"
|
||||
@@ -112,6 +122,25 @@ msgstr "Kopiert!"
|
||||
msgid "Press Ctrl-C to copy!"
|
||||
msgstr "Drücke Strg+C zum kopieren!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:18
|
||||
msgid "An error has occurred."
|
||||
msgstr "Ein Fehler ist aufgetreten."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:23
|
||||
msgid "An error of type {code} occurred."
|
||||
msgstr "Ein Fehler vom Typ {code} ist aufgetreten."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:25
|
||||
msgid ""
|
||||
"We currently cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
"Wir können den Server aktuell nicht erreichen. Bitte versuche es noch "
|
||||
"einmal. Fehlercode: {code}"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:52
|
||||
msgid "Generating messages …"
|
||||
msgstr "Generiere Nachrichten…"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:43
|
||||
msgid "Unknown error."
|
||||
msgstr "Unbekannter Fehler."
|
||||
|
||||
@@ -3,6 +3,7 @@ import io
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.exporter import BaseExporter
|
||||
@@ -70,10 +71,10 @@ class CSVCheckinList(BaseCheckinList):
|
||||
order__event=self.event, item_id__in=form_data['items']
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
).select_related('order', 'item', 'variation')
|
||||
).select_related('order', 'item', 'variation', 'addon_to')
|
||||
|
||||
if form_data['sort'] == 'name':
|
||||
qs = qs.order_by('attendee_name')
|
||||
qs = qs.order_by(Coalesce('attendee_name', 'addon_to__attendee_name'))
|
||||
elif form_data['sort'] == 'code':
|
||||
qs = qs.order_by('order__code')
|
||||
|
||||
@@ -89,6 +90,9 @@ class CSVCheckinList(BaseCheckinList):
|
||||
if form_data['secrets']:
|
||||
headers.append(_('Secret'))
|
||||
|
||||
if self.event.settings.attendee_emails_asked:
|
||||
headers.append(_('E-mail'))
|
||||
|
||||
for q in questions:
|
||||
headers.append(str(q.question))
|
||||
|
||||
@@ -97,7 +101,7 @@ class CSVCheckinList(BaseCheckinList):
|
||||
for op in qs:
|
||||
row = [
|
||||
op.order.code,
|
||||
op.attendee_name,
|
||||
op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
|
||||
str(op.item.name) + (" – " + str(op.variation.value) if op.variation else ""),
|
||||
op.price,
|
||||
]
|
||||
@@ -105,6 +109,8 @@ class CSVCheckinList(BaseCheckinList):
|
||||
row.append(_('Yes') if op.order.status == Order.STATUS_PAID else _('No'))
|
||||
if form_data['secrets']:
|
||||
row.append(op.secret)
|
||||
if self.event.settings.attendee_emails_asked:
|
||||
row.append(op.attendee_email or (op.addon_to.attendee_name if op.addon_to else ''))
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
acache[a.question_id] = str(a)
|
||||
|
||||
@@ -74,7 +74,7 @@ class ApiRedeemView(ApiView):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = False
|
||||
op = OrderPosition.objects.select_related('item', 'variation', 'order').get(
|
||||
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
|
||||
order__event=self.event, secret=secret
|
||||
)
|
||||
if op.order.status == Order.STATUS_PAID:
|
||||
@@ -105,7 +105,7 @@ class ApiRedeemView(ApiView):
|
||||
'order': op.order.code,
|
||||
'item': str(op.item),
|
||||
'variation': str(op.variation) if op.variation else None,
|
||||
'attendee_name': op.attendee_name
|
||||
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
|
||||
}
|
||||
|
||||
except OrderPosition.DoesNotExist:
|
||||
@@ -136,7 +136,7 @@ class ApiSearchView(ApiView):
|
||||
'order': op.order.code,
|
||||
'item': str(op.item),
|
||||
'variation': str(op.variation) if op.variation else None,
|
||||
'attendee_name': op.attendee_name,
|
||||
'attendee_name': op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''),
|
||||
'redeemed': bool(op.checkins.all()),
|
||||
'paid': op.order.status == Order.STATUS_PAID,
|
||||
} for op in ops
|
||||
@@ -201,7 +201,7 @@ class ApiStatusView(ApiView):
|
||||
}
|
||||
|
||||
response['items'] = []
|
||||
for item in self.event.items.prefetch_related('variations'):
|
||||
for item in self.event.items.order_by('pk').prefetch_related('variations'):
|
||||
i = {
|
||||
'id': item.pk,
|
||||
'name': str(item),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@ from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
|
||||
|
||||
from pretix.base.forms import PlaceholderValidator
|
||||
from pretix.base.models import Order
|
||||
|
||||
|
||||
@@ -21,7 +22,9 @@ class MailForm(forms.Form):
|
||||
widget=I18nTextarea, required=True,
|
||||
locales=event.settings.get('locales'),
|
||||
help_text=_("Available placeholders: {due_date}, {event}, {order}, {order_date}, {order_url}, "
|
||||
"{invoice_name}, {invoice_company}")
|
||||
"{invoice_name}, {invoice_company}"),
|
||||
validators=[PlaceholderValidator(['{due_date}', '{event}', '{order}', '{order_date}', '{order_url}',
|
||||
'{invoice_name}', '{invoice_company}'])]
|
||||
)
|
||||
choices = list(Order.STATUS_CHOICE)
|
||||
if not event.settings.get('payment_term_expire_automatically', as_type=bool):
|
||||
|
||||
@@ -13,22 +13,15 @@
|
||||
{% if request.method == "POST" %}
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail preview" %}</legend>
|
||||
{% for locale, segments in output.items %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<strong>{% blocktrans %}For locale: {{ locale }}{% endblocktrans %}</strong>
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="tab-pane mail-preview-group">
|
||||
{% for locale, segments in output.items %}
|
||||
<pre lang="{{ locale }}" class="mail-preview">{% spaceless %}
|
||||
{% for value in segments %}
|
||||
<p>
|
||||
{{ value|linebreaksbr }}
|
||||
</p>
|
||||
{{ value|linebreaksbr }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endspaceless %}</pre>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<div class="form-group submit-group">
|
||||
|
||||
@@ -65,14 +65,14 @@ class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView)
|
||||
for p in (OrderPosition.objects
|
||||
.filter(order__event=self.request.event)
|
||||
.values('item')
|
||||
.annotate(cnt=Count('id')))
|
||||
.annotate(cnt=Count('id')).order_by())
|
||||
}
|
||||
num_paid = {
|
||||
p['item']: p['cnt']
|
||||
for p in (OrderPosition.objects
|
||||
.filter(order__event=self.request.event, order__status=Order.STATUS_PAID)
|
||||
.values('item')
|
||||
.annotate(cnt=Count('id')))
|
||||
.annotate(cnt=Count('id')).order_by())
|
||||
}
|
||||
item_names = {
|
||||
i.id: str(i.name)
|
||||
|
||||
@@ -91,6 +91,8 @@ class PdfTicketOutput(BaseTicketOutput):
|
||||
buffer = BytesIO()
|
||||
p = self._create_canvas(buffer)
|
||||
for op in order.positions.all():
|
||||
if op.addon_to_id and not self.event.settings.ticket_download_addons:
|
||||
continue
|
||||
self._draw_page(p, op, order)
|
||||
p.save()
|
||||
outbuffer = self._render_with_background(buffer)
|
||||
|
||||
@@ -11,19 +11,24 @@ from django.views.generic.base import TemplateResponseMixin
|
||||
|
||||
from pretix.base.models import Order
|
||||
from pretix.base.models.orders import InvoiceAddress
|
||||
from pretix.base.services.cart import set_cart_addons
|
||||
from pretix.base.services.orders import perform_order
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.forms.checkout import ContactForm, InvoiceAddressForm
|
||||
from pretix.presale.forms.checkout import (
|
||||
AddOnsForm, ContactForm, InvoiceAddressForm,
|
||||
)
|
||||
from pretix.presale.signals import (
|
||||
checkout_confirm_messages, checkout_flow_steps, order_meta_from_request,
|
||||
)
|
||||
from pretix.presale.views import CartMixin, get_cart_total
|
||||
from pretix.presale.views import CartMixin, get_cart, get_cart_total
|
||||
from pretix.presale.views.async import AsyncAction
|
||||
from pretix.presale.views.questions import QuestionsViewMixin
|
||||
|
||||
|
||||
class BaseCheckoutFlowStep:
|
||||
requires_valid_cart = True
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
self.request = None
|
||||
@@ -126,6 +131,120 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
priority = 40
|
||||
identifier = "addons"
|
||||
template_name = "pretixpresale/event/checkout_addons.html"
|
||||
task = set_cart_addons
|
||||
known_errortypes = ['CartError']
|
||||
requires_valid_cart = False
|
||||
|
||||
def is_applicable(self, request):
|
||||
return get_cart(request).filter(item__addons__isnull=False).exists()
|
||||
|
||||
def is_completed(self, request, warn=False):
|
||||
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
|
||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
|
||||
):
|
||||
a = cartpos.addons.all()
|
||||
for iao in cartpos.item.addons.all():
|
||||
found = len([1 for p in a if p.item.category_id == iao.addon_category_id])
|
||||
if found < iao.min_count or found > iao.max_count:
|
||||
return False
|
||||
return True
|
||||
|
||||
@cached_property
|
||||
def forms(self):
|
||||
"""
|
||||
A list of forms with one form for each cart position that can have add-ons.
|
||||
All forms have a custom prefix, so that they can all be submitted at once.
|
||||
"""
|
||||
formset = []
|
||||
quota_cache = {}
|
||||
item_cache = {}
|
||||
for cartpos in get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
|
||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation'
|
||||
):
|
||||
current_addon_products = {
|
||||
a.item_id: a.variation_id for a in cartpos.addons.all()
|
||||
}
|
||||
formsetentry = {
|
||||
'cartpos': cartpos,
|
||||
'item': cartpos.item,
|
||||
'variation': cartpos.variation,
|
||||
'categories': []
|
||||
}
|
||||
for iao in cartpos.item.addons.all():
|
||||
category = {
|
||||
'category': iao.addon_category,
|
||||
'min_count': iao.min_count,
|
||||
'max_count': iao.max_count,
|
||||
'form': AddOnsForm(
|
||||
event=self.request.event,
|
||||
prefix='{}_{}'.format(cartpos.pk, iao.addon_category.pk),
|
||||
category=iao.addon_category,
|
||||
initial=current_addon_products,
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
quota_cache=quota_cache,
|
||||
item_cache=item_cache
|
||||
)
|
||||
}
|
||||
|
||||
if len(category['form'].fields) > 0:
|
||||
formsetentry['categories'].append(category)
|
||||
|
||||
formset.append(formsetentry)
|
||||
return formset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['forms'] = self.forms
|
||||
return ctx
|
||||
|
||||
def get_success_message(self, value):
|
||||
return None
|
||||
|
||||
def get_success_url(self, value):
|
||||
return self.get_next_url(self.request)
|
||||
|
||||
def get_error_url(self):
|
||||
return self.get_step_url()
|
||||
|
||||
def get(self, request):
|
||||
self.request = request
|
||||
if 'async_id' in request.GET and settings.HAS_CELERY:
|
||||
return self.get_result(request)
|
||||
return TemplateFlowStep.get(self, request)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
is_valid = True
|
||||
data = []
|
||||
for f in self.forms:
|
||||
for c in f['categories']:
|
||||
is_valid = is_valid and c['form'].is_valid()
|
||||
if c['form'].is_valid():
|
||||
for k, v in c['form'].cleaned_data.items():
|
||||
itemid = int(k[5:])
|
||||
if v is True:
|
||||
data.append({
|
||||
'addon_to': f['cartpos'].pk,
|
||||
'item': itemid,
|
||||
'variation': None
|
||||
})
|
||||
elif v:
|
||||
data.append({
|
||||
'addon_to': f['cartpos'].pk,
|
||||
'item': itemid,
|
||||
'variation': int(v)
|
||||
})
|
||||
|
||||
if not is_valid:
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
return self.do(self.request.event.id, data, self.request.session.session_key)
|
||||
|
||||
|
||||
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
priority = 50
|
||||
identifier = "questions"
|
||||
@@ -202,11 +321,16 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
if warn:
|
||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
||||
return False
|
||||
if cp.item.admission and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
|
||||
and cp.attendee_email is None:
|
||||
if warn:
|
||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['forms'] = self.forms
|
||||
ctx['formgroups'] = self.formdict.items()
|
||||
ctx['contact_form'] = self.contact_form
|
||||
ctx['invoice_form'] = self.invoice_form
|
||||
return ctx
|
||||
@@ -390,6 +514,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
|
||||
|
||||
DEFAULT_FLOW = (
|
||||
AddOnsStep,
|
||||
QuestionsStep,
|
||||
PaymentStep,
|
||||
ConfirmStep
|
||||
|
||||
@@ -2,14 +2,25 @@ from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.forms.widgets import RadioChoiceInput, RadioFieldRenderer
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.formats import number_format
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Question
|
||||
from pretix.base.models import ItemVariation, Question
|
||||
from pretix.base.models.orders import InvoiceAddress
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
email = forms.EmailField(label=_('E-mail'))
|
||||
email = forms.EmailField(label=_('E-mail'),
|
||||
help_text=_('Make sure to enter a valid email address. We will send you an order '
|
||||
'confirmation including a link that you need in case you want to make '
|
||||
'modifications to your order or download your ticket later.'))
|
||||
|
||||
|
||||
class InvoiceAddressForm(forms.ModelForm):
|
||||
@@ -53,8 +64,8 @@ class QuestionsForm(forms.Form):
|
||||
:param cartpos: The cart position the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = kwargs.pop('orderpos', None)
|
||||
cartpos = self.cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
item = cartpos.item if cartpos else orderpos.item
|
||||
questions = list(item.questions.all())
|
||||
event = kwargs.pop('event')
|
||||
@@ -67,6 +78,12 @@ class QuestionsForm(forms.Form):
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name)
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
required=event.settings.attendee_emails_required,
|
||||
label=_('Attendee email'),
|
||||
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
|
||||
)
|
||||
|
||||
for q in questions:
|
||||
# Do we already have an answer? Provide it as the initial value
|
||||
@@ -132,3 +149,171 @@ class QuestionsForm(forms.Form):
|
||||
# Cache the answer object for later use
|
||||
field.answer = answers[0]
|
||||
self.fields['question_%s' % q.id] = field
|
||||
|
||||
|
||||
# The following will get totally different once Django 1.11 is integrated
|
||||
class AddOnVariationSelectInput(RadioChoiceInput):
|
||||
|
||||
def __init__(self, name, value, attrs, choice, index):
|
||||
super().__init__(name, value, attrs, choice, index)
|
||||
self.description = force_text(choice[2])
|
||||
|
||||
def render(self, name=None, value=None, attrs=None):
|
||||
if self.id_for_label:
|
||||
label_for = format_html(' for="{}"', self.id_for_label)
|
||||
else:
|
||||
label_for = ''
|
||||
attrs = dict(self.attrs, **attrs) if attrs else self.attrs
|
||||
if self.description:
|
||||
return format_html(
|
||||
'<label{}>{} {}</label> <span class="fa fa-info-circle toggle-variation-description"></span>'
|
||||
'<div class="variation-description addon-variation-description">{}</div>',
|
||||
label_for, self.tag(attrs), self.choice_label,
|
||||
rich_text(str(self.description))
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<label{}>{} {}</label>',
|
||||
label_for, self.tag(attrs), self.choice_label,
|
||||
)
|
||||
|
||||
|
||||
class AddOnVariationSelectRenderer(RadioFieldRenderer):
|
||||
choice_input_class = AddOnVariationSelectInput
|
||||
|
||||
def render(self):
|
||||
id_ = self.attrs.get('id')
|
||||
output = []
|
||||
for i, choice in enumerate(self.choices):
|
||||
w = self.choice_input_class(self.name, self.value, self.attrs.copy(), choice, i)
|
||||
output.append(format_html(self.inner_html, choice_value=force_text(w), sub_widgets=''))
|
||||
return format_html(
|
||||
self.outer_html,
|
||||
id_attr=format_html(' id="{}"', id_) if id_ else '',
|
||||
content=mark_safe('\n'.join(output)),
|
||||
)
|
||||
|
||||
|
||||
class AddOnVariationSelect(forms.RadioSelect):
|
||||
renderer = AddOnVariationSelectRenderer
|
||||
|
||||
|
||||
class AddOnVariationField(forms.ChoiceField):
|
||||
|
||||
def valid_value(self, value):
|
||||
text_value = force_text(value)
|
||||
for k, v, d in self.choices:
|
||||
if value == k or text_value == force_text(k):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AddOnsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for selecting add-ons to a product in the cart.
|
||||
"""
|
||||
|
||||
def _label(self, event, item_or_variation, avail):
|
||||
if isinstance(item_or_variation, ItemVariation):
|
||||
variation = item_or_variation
|
||||
item = item_or_variation.item
|
||||
price = variation.price
|
||||
price_net = variation.net_price
|
||||
label = variation.value
|
||||
else:
|
||||
item = item_or_variation
|
||||
price = item.default_price
|
||||
price_net = item.default_price_net
|
||||
label = item.name
|
||||
|
||||
if not price:
|
||||
n = '{name}'.format(
|
||||
name=label
|
||||
)
|
||||
elif not item.tax_rate:
|
||||
n = _('{name} (+ {currency} {price})').format(
|
||||
name=label, currency=event.currency, price=number_format(price)
|
||||
)
|
||||
elif event.settings.display_net_prices:
|
||||
n = _('{name} (+ {currency} {price} plus {taxes}% taxes)').format(
|
||||
name=label, currency=event.currency, price=number_format(price_net),
|
||||
taxes=number_format(item.tax_rate)
|
||||
)
|
||||
else:
|
||||
n = _('{name} (+ {currency} {price} incl. {taxes}% taxes)').format(
|
||||
name=label, currency=event.currency, price=number_format(price),
|
||||
taxes=number_format(item.tax_rate)
|
||||
)
|
||||
|
||||
if avail[0] < 20:
|
||||
n += ' – {}'.format(_('SOLD OUT'))
|
||||
elif avail[0] < 100:
|
||||
n += ' – {}'.format(_('Currently unavailable'))
|
||||
|
||||
return n
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Takes additional keyword arguments:
|
||||
|
||||
:param category: The category to choose from
|
||||
:param event: The event this belongs to
|
||||
:param initial: The current set of add-ons
|
||||
:param quota_cache: A shared dictionary for quota caching
|
||||
:param item_cache: A shared dictionary for item/category caching
|
||||
"""
|
||||
category = kwargs.pop('category')
|
||||
event = kwargs.pop('event')
|
||||
current_addons = kwargs.pop('initial')
|
||||
quota_cache = kwargs.pop('quota_cache')
|
||||
item_cache = kwargs.pop('item_cache')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if category.pk not in item_cache:
|
||||
# Get all items to possibly show
|
||||
items = category.items.filter(
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(hide_without_voucher=False)
|
||||
).prefetch_related(
|
||||
'variations__quotas', # for .availability()
|
||||
Prefetch('quotas', queryset=event.quotas.all()),
|
||||
Prefetch('variations', to_attr='available_variations',
|
||||
queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()),
|
||||
).annotate(
|
||||
quotac=Count('quotas'),
|
||||
has_variations=Count('variations')
|
||||
).filter(
|
||||
quotac__gt=0
|
||||
).order_by('category__position', 'category_id', 'position', 'name')
|
||||
item_cache[category.pk] = items
|
||||
else:
|
||||
items = item_cache[category.pk]
|
||||
|
||||
for i in items:
|
||||
if i.has_variations:
|
||||
choices = [('', _('no selection'), '')]
|
||||
for v in i.available_variations:
|
||||
cached_availability = v.check_quotas(_cache=quota_cache)
|
||||
choices.append((v.pk, self._label(event, v, cached_availability), v.description))
|
||||
|
||||
field = AddOnVariationField(
|
||||
choices=choices,
|
||||
label=i.name,
|
||||
required=False,
|
||||
widget=AddOnVariationSelect,
|
||||
help_text=rich_text(str(i.description)),
|
||||
initial=current_addons.get(i.pk),
|
||||
)
|
||||
else:
|
||||
cached_availability = i.check_quotas(_cache=quota_cache)
|
||||
field = forms.BooleanField(
|
||||
label=self._label(event, i, cached_availability),
|
||||
required=False,
|
||||
initial=i.pk in current_addons,
|
||||
help_text=rich_text(str(i.description)),
|
||||
)
|
||||
|
||||
self.fields['item_%s' % i.pk] = field
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from pretix.base.models import EventSetting
|
||||
from pretix.base.models import Event_SettingsStore
|
||||
|
||||
from ...style import regenerate_css
|
||||
|
||||
@@ -9,5 +9,5 @@ class Command(BaseCommand):
|
||||
help = "Re-generate all custom stylesheets"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
for es in EventSetting.objects.filter(key="presale_css_file"):
|
||||
for es in Event_SettingsStore.objects.filter(key="presale_css_file"):
|
||||
regenerate_css.apply_async(args=(es.object_id,))
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "pretixpresale/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Checkout" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h2>{% trans "Checkout" %}</h2>
|
||||
<p>
|
||||
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
|
||||
</p>
|
||||
<form class="form-horizontal" method="post" data-asynctask>
|
||||
{% csrf_token %}
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% for form in forms %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#cp{{ form.cartpos.pk }}">
|
||||
<strong>{{ form.item.name }}</strong>
|
||||
{% if form.variation %}
|
||||
– {{ form.variation }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="cp{{ form.cartpos.pk }}"
|
||||
class="panel-collapse collapsed in">
|
||||
<div class="panel-body">
|
||||
{% for c in form.categories %}
|
||||
<fieldset>
|
||||
<legend>{{ c.category.name }}</legend>
|
||||
<p>
|
||||
{% if c.min_count == c.max_count %}
|
||||
{% blocktrans trimmed with min_count=c.min_count %}
|
||||
You need to choose {{ min_count }} options from this category.
|
||||
{% endblocktrans %}
|
||||
{% elif c.min_count == 0 %}
|
||||
{% blocktrans trimmed with max_count=c.max_count %}
|
||||
You can to choose up to {{ max_count }} options from this category.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with min_count=c.min_count max_count=c.max_count %}
|
||||
You can choose between {{ min_count }} and {{ max_count }} options from
|
||||
this category.
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% bootstrap_form c.form layout="horizontal" %}
|
||||
</fieldset>
|
||||
{% empty %}
|
||||
<em>
|
||||
{% trans "There are no add-ons available for this product." %}
|
||||
</em>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4">
|
||||
<a class="btn btn-block btn-default btn-lg"
|
||||
href="{{ prev_url }}">
|
||||
{% trans "Go back" %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4 col-md-offset-4">
|
||||
<button class="btn btn-block btn-primary btn-lg" type="submit">
|
||||
{% trans "Continue" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -42,14 +42,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for form in forms %}
|
||||
{% for pos, forms in formgroups %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#cp{{ form.pos.id }}">
|
||||
<strong>{{ form.pos.item.name }}</strong>
|
||||
{% if form.pos.variation %}
|
||||
– {{ form.pos.variation }}
|
||||
<a data-toggle="collapse" href="#cp{{ pos.id }}">
|
||||
<strong>{{ pos.item.name }}</strong>
|
||||
{% if pos.variation %}
|
||||
– {{ pos.variation }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if forloop.counter > 1 %}
|
||||
@@ -59,10 +59,16 @@
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div id="cp{{ form.pos.id }}"
|
||||
<div id="cp{{ pos.id }}"
|
||||
class="panel-collapse collapsed in">
|
||||
<div class="panel-body" data-idx="{{ forloop.counter0 }}">
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
{% for form in forms %}
|
||||
{% if form.pos.item != pos.item %}
|
||||
{# Add-Ons #}
|
||||
<legend>+ {{ form.pos.item }}</legend>
|
||||
{% endif %}
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
{% for line in cart.positions %}
|
||||
<div class="row cart-row {% if download %}has-downloads{% endif %}">
|
||||
<div class="product">
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
{% endif %}
|
||||
<strong>{{ line.item.name }}</strong>
|
||||
{% if line.variation %}
|
||||
– {{ line.variation }}
|
||||
@@ -18,6 +21,10 @@
|
||||
<dt>{% trans "Attendee name" %}</dt>
|
||||
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if line.item.admission and event.settings.attendee_emails_asked%}
|
||||
<dt>{% trans "Attendee email" %}</dt>
|
||||
<dd>{% if line.attendee_email %}{{ line.attendee_email }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% for q in line.questions %}
|
||||
<dt>{{ q.question }}</dt>
|
||||
<dd>{% if q.answer %}{{ q.answer|linebreaksbr }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}</dd>
|
||||
@@ -28,12 +35,23 @@
|
||||
|
||||
{% if download %}
|
||||
<div class="download-desktop">
|
||||
{% for b in download_buttons %}
|
||||
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
|
||||
class="btn btn-default btn-sm" data-asyncdownload>
|
||||
<span class="fa fa-download"></span> {{ b.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not line.addon_to or event.settings.ticket_download_addons %}
|
||||
{% for b in download_buttons %}
|
||||
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
|
||||
class="btn btn-default btn-sm" data-asyncdownload>
|
||||
<span class="fa fa-download"></span> {{ b.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif line.addon_to %}
|
||||
<div class="count"> </div>
|
||||
<div class="singleprice price">
|
||||
{% if event.settings.display_net_prices %}
|
||||
{{ event.currency }} {{ line.net_price|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ event.currency }} {{ line.price|floatformat:2 }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="count">
|
||||
@@ -41,19 +59,10 @@
|
||||
<form action="{% eventurl event "presale:event.cart.remove" %}"
|
||||
method="post" data-asynctask>
|
||||
{% csrf_token %}
|
||||
|
||||
{% if line.variation %}
|
||||
<input type="hidden" name="variation_{{ line.item.id }}_{{ line.variation.id }}"
|
||||
value="1" />
|
||||
<input type="hidden" name="price_{{ line.item.id }}_{{ line.variation.id }}"
|
||||
value="{{ line.price }}" />
|
||||
{% else %}
|
||||
<input type="hidden" name="item_{{ line.item.id }}"
|
||||
value="1" />
|
||||
<input type="hidden" name="price_{{ line.item.id }}"
|
||||
value="{{ line.price }}" />
|
||||
{% endif %}
|
||||
<button class="btn btn-mini btn-link"><i class="fa fa-minus"></i></button>
|
||||
<input type="hidden" name="id" value="{{ line.id }}" />
|
||||
<button class="btn btn-mini btn-link">
|
||||
<i class="fa fa-minus"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{{ line.count }}
|
||||
@@ -72,7 +81,9 @@
|
||||
<input type="hidden" name="price_{{ line.item.id }}"
|
||||
value="{% if event.settings.display_net_prices %}{{ line.net_price }}{% else %}{{ line.price }}{% endif %}" />
|
||||
{% endif %}
|
||||
<button class="btn btn-mini btn-link"><i class="fa fa-plus"></i></button>
|
||||
<button class="btn btn-mini btn-link">
|
||||
<i class="fa fa-plus"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -103,12 +114,14 @@
|
||||
</div>
|
||||
{% if download %}
|
||||
<div class="download-mobile">
|
||||
{% for b in download_buttons %}
|
||||
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
|
||||
class="btn btn-default btn-sm" data-asyncdownload>
|
||||
<span class="fa fa-download"></span> {{ b.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not line.addon_to or event.settings.ticket_download_addons %}
|
||||
{% for b in download_buttons %}
|
||||
<a href="{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.id %}"
|
||||
class="btn btn-default btn-sm" data-asyncdownload>
|
||||
<span class="fa fa-download"></span> {{ b.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -26,24 +26,20 @@
|
||||
</em>
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4 col-xs-12">
|
||||
<form method="post" data-asynctask action="{% eventurl request.event "presale:event.cart.remove" %}" >
|
||||
<form method="post" data-asynctask action="{% eventurl request.event "presale:event.cart.clear" %}">
|
||||
{% csrf_token %}
|
||||
{% for line in cart.positions %}
|
||||
{% if line.variation %}
|
||||
<input type="hidden" name="variation_{{ line.item.id }}_{{ line.variation.id }}" value="{{ line.count }}" />
|
||||
<input type="hidden" name="price_{{ line.item.id }}_{{ line.variation.id }}" value="{{ line.price }}" />
|
||||
{% else %}
|
||||
<input type="hidden" name="item_{{ line.item.id }}" value="{{ line.count }}" />
|
||||
<input type="hidden" name="price_{{ line.item.id }}" value="{{ line.price }}" />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button class="btn btn-block btn-default btn-lg" type="submit"><i class="fa fa-close"></i> {% trans "Empty cart" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4 col-md-offset-4 col-xs-12">
|
||||
<a class="btn btn-block btn-primary btn-lg"
|
||||
href="{% eventurl request.event "presale:event.checkout.start" %}">
|
||||
<i class="fa fa-shopping-cart"></i> {% trans "Proceed with checkout" %}
|
||||
{% if has_addon_choices %}
|
||||
<i class="fa fa-shopping-cart"></i> {% trans "Continue" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<i class="fa fa-shopping-cart"></i> {% trans "Proceed with checkout" %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
@@ -110,7 +106,17 @@
|
||||
<a href="#" data-toggle="variations">
|
||||
<strong>{{ item.name }}</strong>
|
||||
</a>
|
||||
{% if item.description %}<p>{{ item.description|localize|rich_text }}</p>
|
||||
{% if item.description %}
|
||||
<div class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.min_per_order %}
|
||||
<p><small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
@@ -136,6 +142,11 @@
|
||||
<div class="row-fluid product-row variation">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{{ var }}
|
||||
{% if var.description %}
|
||||
<div class="variation-description">
|
||||
{{ var.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.settings.show_quota_left %}
|
||||
{% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %}
|
||||
{% endif %}
|
||||
@@ -203,10 +214,20 @@
|
||||
{% endif %}
|
||||
<strong>{{ item.name }}</strong>
|
||||
{% if item.description %}
|
||||
<p class="description">{{ item.description|localize|rich_text }}</p>{% endif %}
|
||||
<div class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if event.settings.show_quota_left %}
|
||||
{% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %}
|
||||
{% endif %}
|
||||
{% if item.min_per_order %}
|
||||
<p><small>
|
||||
{% blocktrans trimmed with num=item.min_per_order %}
|
||||
minimum amount to order: {{ num }}
|
||||
{% endblocktrans %}
|
||||
</small></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if item.free_price %}
|
||||
|
||||
@@ -36,23 +36,28 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for form in forms %}
|
||||
{% for pos, forms in formgroups %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<a data-toggle="collapse" href="#cp{{ form.pos.id }}"
|
||||
data-parent="#questions_accordion">
|
||||
<strong>{{ form.pos.item }}</strong>
|
||||
{% if form.pos.variation %}
|
||||
– {{ form.pos.variation }}
|
||||
<a data-toggle="collapse" href="#cp{{ pos.id }}">
|
||||
<strong>{{ pos.item.name }}</strong>
|
||||
{% if pos.variation %}
|
||||
– {{ pos.variation }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="cp{{ form.pos.id }}"
|
||||
class="panel-collapse collapsed {% if forloop.counter0 == 0 %}in{% endif %}">
|
||||
<div id="cp{{ pos.id }}"
|
||||
class="panel-collapse collapsed in">
|
||||
<div class="panel-body">
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
{% for form in forms %}
|
||||
{% if form.pos.item != pos.item %}
|
||||
{# Add-Ons #}
|
||||
<legend>+ {{ form.pos.item }}</legend>
|
||||
{% endif %}
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
{% endif %}
|
||||
<strong>{{ item.name }}</strong>
|
||||
{% if item.description %}
|
||||
<p>{{ item.description|localize|rich_text }}</p>{% endif %}
|
||||
<div class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if item.min_price != item.max_price or item.free_price %}
|
||||
@@ -57,6 +60,11 @@
|
||||
<div class="row-fluid product-row variation">
|
||||
<div class="col-md-8 col-xs-12">
|
||||
{{ var }}
|
||||
{% if var.description %}
|
||||
<div class="variation-description">
|
||||
{{ var.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if item.free_price %}
|
||||
@@ -116,7 +124,10 @@
|
||||
{% endif %}
|
||||
<strong>{{ item.name }}</strong>
|
||||
{% if item.description %}
|
||||
<p class="description">{{ item.description|localize|rich_text }}</p>{% endif %}
|
||||
<div class="product-description">
|
||||
{{ item.description|localize|rich_text }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-6 price">
|
||||
{% if item.free_price %}
|
||||
|
||||
@@ -15,9 +15,10 @@ import pretix.presale.views.waiting
|
||||
event_patterns = [
|
||||
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
|
||||
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
|
||||
url(r'^cart/clear$', pretix.presale.views.cart.CartClear.as_view(), name='event.cart.clear'),
|
||||
url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
|
||||
url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
|
||||
url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(),
|
||||
url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(),
|
||||
name='event.redeem'),
|
||||
url(r'^checkout/(?P<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(),
|
||||
name='event.checkout'),
|
||||
|
||||
@@ -29,30 +29,45 @@ class CartMixin:
|
||||
cartpos = queryset.order_by(
|
||||
'item', 'variation'
|
||||
).select_related(
|
||||
'item', 'variation'
|
||||
'item', 'variation', 'addon_to'
|
||||
).prefetch_related(
|
||||
*prefetch
|
||||
)
|
||||
else:
|
||||
cartpos = self.positions
|
||||
|
||||
lcp = list(cartpos)
|
||||
has_addons = {cp.addon_to.pk for cp in lcp if cp.addon_to}
|
||||
|
||||
# Group items of the same variation
|
||||
# We do this by list manipulations instead of a GROUP BY query, as
|
||||
# Django is unable to join related models in a .values() query
|
||||
def keyfunc(pos):
|
||||
if isinstance(pos, OrderPosition):
|
||||
i = pos.positionid
|
||||
if pos.addon_to:
|
||||
i = pos.addon_to.positionid
|
||||
else:
|
||||
i = pos.positionid
|
||||
else:
|
||||
i = pos.pk
|
||||
if downloads:
|
||||
return i, pos.pk, 0, 0, 0, 0,
|
||||
if answers and ((pos.item.admission and self.request.event.settings.attendee_names_asked)
|
||||
or pos.item.questions.all()):
|
||||
return i, pos.pk, 0, 0, 0, 0,
|
||||
return 0, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0)
|
||||
if pos.addon_to:
|
||||
i = pos.addon_to.pk
|
||||
else:
|
||||
i = pos.pk
|
||||
|
||||
has_attendee_data = pos.item.admission and (
|
||||
self.request.event.settings.attendee_names_asked
|
||||
or self.request.event.settings.attendee_emails_asked
|
||||
)
|
||||
addon_penalty = 1 if pos.addon_to else 0
|
||||
if downloads or pos.pk in has_addons or pos.addon_to:
|
||||
return i, addon_penalty, pos.pk, 0, 0, 0, 0,
|
||||
if answers and (has_attendee_data or pos.item.questions.all()):
|
||||
return i, addon_penalty, pos.pk, 0, 0, 0, 0,
|
||||
|
||||
return 0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0)
|
||||
|
||||
positions = []
|
||||
for k, g in groupby(sorted(list(cartpos), key=keyfunc), key=keyfunc):
|
||||
for k, g in groupby(sorted(lcp, key=keyfunc), key=keyfunc):
|
||||
g = list(g)
|
||||
group = g[0]
|
||||
group.count = len(g)
|
||||
@@ -94,7 +109,7 @@ class CartMixin:
|
||||
'payment_fee_tax_rate': payment_fee_tax_rate,
|
||||
'answers': answers,
|
||||
'minutes_left': minutes_left,
|
||||
'first_expiry': first_expiry
|
||||
'first_expiry': first_expiry,
|
||||
}
|
||||
|
||||
def get_payment_fee(self, total):
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.views.generic import TemplateView, View
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import CartPosition, Quota, Voucher
|
||||
from pretix.base.services.cart import (
|
||||
CartError, add_items_to_cart, remove_items_from_cart,
|
||||
CartError, add_items_to_cart, clear_cart, remove_cart_position,
|
||||
)
|
||||
from pretix.multidomain.urlreverse import eventreverse
|
||||
from pretix.presale.views import EventViewMixin
|
||||
@@ -105,19 +105,18 @@ class CartActionMixin:
|
||||
|
||||
|
||||
class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
task = remove_items_from_cart
|
||||
task = remove_cart_position
|
||||
known_errortypes = ['CartError']
|
||||
|
||||
def get_success_message(self, value):
|
||||
if CartPosition.objects.filter(cart_id=self.request.session.session_key).exists():
|
||||
return _('Your cart has been updated.')
|
||||
else:
|
||||
return _('Your cart is empty.')
|
||||
return _('Your cart is now empty.')
|
||||
|
||||
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, translation.get_language())
|
||||
if 'id' in request.POST:
|
||||
return self.do(self.request.event.id, request.POST.get('id'), self.request.session.session_key, translation.get_language())
|
||||
else:
|
||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||
return JsonResponse({
|
||||
@@ -127,6 +126,17 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
return redirect(self.get_error_url())
|
||||
|
||||
|
||||
class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
task = clear_cart
|
||||
known_errortypes = ['CartError']
|
||||
|
||||
def get_success_message(self, value):
|
||||
return _('Your cart is now empty.')
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.do(self.request.event.id, self.request.session.session_key, translation.get_language())
|
||||
|
||||
|
||||
class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
task = add_items_to_cart
|
||||
known_errortypes = ['CartError']
|
||||
@@ -161,6 +171,7 @@ class RedeemView(EventViewMixin, TemplateView):
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& ~Q(category__is_addon=True)
|
||||
)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
|
||||
@@ -22,16 +22,22 @@ class CheckoutView(View):
|
||||
messages.error(request, _("Your cart is empty"))
|
||||
return redirect(eventreverse(self.request.event, 'presale:event.index'))
|
||||
|
||||
cart_error = None
|
||||
try:
|
||||
validate_cart.send(sender=self.request.event, positions=cart_pos)
|
||||
except CartError as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect(eventreverse(self.request.event, 'presale:event.index'))
|
||||
cart_error = e
|
||||
|
||||
flow = get_checkout_flow(self.request.event)
|
||||
previous_step = None
|
||||
for step in flow:
|
||||
if not step.is_applicable(request):
|
||||
continue
|
||||
if step.requires_valid_cart and cart_error:
|
||||
messages.error(request, str(cart_error))
|
||||
return redirect(previous_step.get_step_url() if previous_step
|
||||
else eventreverse(self.request.event, 'presale:event.index'))
|
||||
|
||||
if 'step' not in kwargs:
|
||||
return redirect(step.get_step_url())
|
||||
is_selected = (step.identifier == kwargs.get('step', ''))
|
||||
@@ -43,4 +49,6 @@ class CheckoutView(View):
|
||||
else:
|
||||
handler = self.http_method_not_allowed
|
||||
return handler(request)
|
||||
else:
|
||||
previous_step = step
|
||||
raise Http404()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user