mirror of
https://github.com/pretix/pretix.git
synced 2026-08-15 11:36:27 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e86500e9fc | ||
|
|
a72e32b82d | ||
|
|
2e2fe874e1 | ||
|
|
ce8c4c649d | ||
|
|
430c6dd269 | ||
|
|
6411648457 | ||
|
|
005b3864b1 | ||
|
|
1ee1c604cf | ||
|
|
e425105dbf | ||
|
|
738b375042 | ||
|
|
d09572d999 | ||
|
|
634754aacb | ||
|
|
14c3baa2aa | ||
|
|
dc1b62fc56 | ||
|
|
ea2c81e6dc | ||
|
|
2bd5e90a86 | ||
|
|
8095134400 |
@@ -53,7 +53,7 @@ Working with the code
|
|||||||
---------------------
|
---------------------
|
||||||
If you do not have a recent installation of ``nodejs``, install it now::
|
If you do not have a recent installation of ``nodejs``, install it now::
|
||||||
|
|
||||||
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||||
sudo apt install nodejs
|
sudo apt install nodejs
|
||||||
|
|
||||||
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from django.core.cache import cache
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import close_old_connections
|
from django.db import close_old_connections
|
||||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||||
|
from django_querytagger.tagging import with_tag
|
||||||
|
|
||||||
from pretix.helpers.periodic import SKIPPED
|
from pretix.helpers.periodic import SKIPPED
|
||||||
|
|
||||||
@@ -82,7 +83,8 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
||||||
close_old_connections()
|
close_old_connections()
|
||||||
r = receiver(signal=periodic_task, sender=self)
|
with with_tag(f"periodictask={name}"):
|
||||||
|
r = receiver(signal=periodic_task, sender=self)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
if isinstance(err, KeyboardInterrupt):
|
if isinstance(err, KeyboardInterrupt):
|
||||||
raise err
|
raise err
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import Count
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_duplicate_secrets(apps, schema_editor):
|
||||||
|
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
|
||||||
|
# unless Order.code is already too long to append something. This would need to be fixed by
|
||||||
|
# sysadmins manually.
|
||||||
|
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
|
||||||
|
Order = apps.get_model("pretixbase", "Order")
|
||||||
|
|
||||||
|
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||||
|
for row in qs:
|
||||||
|
affected = OrderPosition.all.filter(
|
||||||
|
**{k: v for k, v in row.items() if k != "c"}
|
||||||
|
).order_by("pk")
|
||||||
|
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
|
||||||
|
for i, a in enumerate(affected):
|
||||||
|
if i > 0:
|
||||||
|
a.secret = a.secret + "__dupl__" + str(a.pk)
|
||||||
|
logger.info(
|
||||||
|
f"Ticket {a.pk} has new secret {a.secret}"
|
||||||
|
)
|
||||||
|
a.save(update_fields=["organizer_id", "secret"])
|
||||||
|
|
||||||
|
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||||
|
for row in qs:
|
||||||
|
affected = Order.objects.filter(
|
||||||
|
**{k: v for k, v in row.items() if k != "c"}
|
||||||
|
).order_by("pk")
|
||||||
|
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
|
||||||
|
for i, a in enumerate(affected):
|
||||||
|
if i > 0:
|
||||||
|
if len(a.code) > 16 - len(str(a.pk)):
|
||||||
|
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
|
||||||
|
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
|
||||||
|
logger.info(
|
||||||
|
f"Order {a.pk} has new code {a.code}"
|
||||||
|
)
|
||||||
|
a.save(update_fields=["organizer_id", "code"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"pretixbase",
|
||||||
|
"0301_reusablemedium_remove_orderposition",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"pretixbase",
|
||||||
|
"0302_resolve_duplicate_codes_and_secrets",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE pretixbase_order "
|
||||||
|
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
|
||||||
|
"WHERE pretixbase_order.organizer_id IS NULL;",
|
||||||
|
migrations.RunSQL.noop,
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE pretixbase_orderposition "
|
||||||
|
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
|
||||||
|
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
|
||||||
|
migrations.RunSQL.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="order",
|
||||||
|
name="organizer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="orders",
|
||||||
|
to="pretixbase.organizer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="orderposition",
|
||||||
|
name="organizer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="order_positions",
|
||||||
|
to="pretixbase.organizer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1403,15 +1403,12 @@ class Event(EventMixin, LoggedModel):
|
|||||||
|
|
||||||
for mp in self.organizer.meta_properties.all():
|
for mp in self.organizer.meta_properties.all():
|
||||||
if mp.required and not self.meta_data.get(mp.name):
|
if mp.required and not self.meta_data.get(mp.name):
|
||||||
issues.append(
|
issues.append(format_html(
|
||||||
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
|
'<a href="{href}{href_hash}">{text}</a>',
|
||||||
property=mp.name,
|
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name),
|
||||||
a_attr='href="%s#id_prop-%d-value"' % (
|
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||||
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
href_hash=f'#id_prop-{mp.pk}-value',
|
||||||
mp.pk
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
responses = event_live_issues.send(self)
|
responses = event_live_issues.send(self)
|
||||||
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
|
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
|
||||||
|
|||||||
@@ -224,8 +224,6 @@ class Order(LockModel, LoggedModel):
|
|||||||
"Organizer",
|
"Organizer",
|
||||||
related_name="orders",
|
related_name="orders",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
)
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
Event,
|
Event,
|
||||||
@@ -329,7 +327,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
default="line",
|
default="line",
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Order")
|
verbose_name = _("Order")
|
||||||
@@ -2541,8 +2539,6 @@ class OrderPosition(AbstractPosition):
|
|||||||
"Organizer",
|
"Organizer",
|
||||||
related_name="order_positions",
|
related_name="order_positions",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
)
|
)
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
@@ -2599,7 +2595,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
all = ScopedManager(organizer='order__event__organizer')
|
all = ScopedManager(organizer='organizer')
|
||||||
objects = ActivePositionManager()
|
objects = ActivePositionManager()
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -32,8 +32,10 @@
|
|||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_quota_get_ignored(old_instance):
|
def get_affected_quotas(quota, item, variation, subevent):
|
||||||
quotas = set()
|
if quota:
|
||||||
was_valid = old_instance and (
|
return {quota}
|
||||||
old_instance.valid_until is None or old_instance.valid_until >= now()
|
elif item and variation:
|
||||||
)
|
return set(variation.quotas.filter(subevent=subevent))
|
||||||
if old_instance and old_instance.block_quota and was_valid:
|
elif item and not item.has_variations:
|
||||||
if old_instance.quota:
|
return set(item.quotas.filter(subevent=subevent))
|
||||||
quotas.add(old_instance.quota)
|
elif item and item.has_variations:
|
||||||
elif old_instance.variation:
|
return set(
|
||||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
Quota.objects.filter(
|
||||||
elif old_instance.item:
|
pk__in=Quota.variations.through.objects.filter(
|
||||||
if old_instance.item.has_variations:
|
itemvariation__item=item,
|
||||||
quotas |= set(
|
quota__subevent=subevent,
|
||||||
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
|
).values('quota_id')
|
||||||
itemvariation__item=old_instance.item,
|
)
|
||||||
quota__subevent=old_instance.subevent,
|
)
|
||||||
).values('quota_id'))
|
else:
|
||||||
)
|
return set()
|
||||||
else:
|
|
||||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
@staticmethod
|
||||||
return quotas
|
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
|
||||||
|
if voucher_data:
|
||||||
|
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
|
||||||
|
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
|
||||||
|
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
|
||||||
|
|
||||||
|
return set()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
|
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
|
||||||
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
|
|||||||
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
|
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
|
||||||
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
|
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||||
|
|
||||||
if quota:
|
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
|
||||||
new_quotas = {quota}
|
if not new_quotas:
|
||||||
elif item and variation:
|
|
||||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
|
||||||
elif item and not item.has_variations:
|
|
||||||
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
|
||||||
elif item and item.has_variations:
|
|
||||||
new_quotas = set(
|
|
||||||
Quota.objects.filter(
|
|
||||||
pk__in=Quota.variations.through.objects.filter(
|
|
||||||
itemvariation__item=item,
|
|
||||||
quota__subevent=data.get('subevent'),
|
|
||||||
).values('quota_id')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||||
'tickets.'))
|
'tickets.'))
|
||||||
|
|
||||||
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
|
|||||||
]
|
]
|
||||||
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
|
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
|
||||||
return ops
|
return ops
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoucherBulkData:
|
||||||
|
item: object
|
||||||
|
variation: object
|
||||||
|
quota: object
|
||||||
|
block_quota: bool
|
||||||
|
valid_until: datetime.datetime
|
||||||
|
subevent: object
|
||||||
|
redeemed: int
|
||||||
|
max_usages: int
|
||||||
|
allow_ignore_quota: bool
|
||||||
|
|||||||
@@ -936,7 +936,7 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
Will be called if the *event administrator* views the details of a payment.
|
Will be called if the *event administrator* views the details of a payment.
|
||||||
|
|
||||||
It should return HTML code containing information regarding the current payment
|
It should return a SafeString containing HTML code, with information regarding the current payment
|
||||||
status and, if applicable, next steps.
|
status and, if applicable, next steps.
|
||||||
|
|
||||||
The default implementation returns an empty string.
|
The default implementation returns an empty string.
|
||||||
@@ -961,7 +961,7 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
Will be called if the *event administrator* views the details of a refund.
|
Will be called if the *event administrator* views the details of a refund.
|
||||||
|
|
||||||
It should return HTML code containing information regarding the current refund
|
It should return a SafeString containing HTML code, with information regarding the current refund
|
||||||
status and, if applicable, next steps.
|
status and, if applicable, next steps.
|
||||||
|
|
||||||
The default implementation returns an empty string.
|
The default implementation returns an empty string.
|
||||||
|
|||||||
@@ -535,8 +535,9 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
|
|||||||
event_live_issues = EventPluginSignal()
|
event_live_issues = EventPluginSignal()
|
||||||
"""
|
"""
|
||||||
This signal is sent out to determine whether an event can be taken live. If you want to
|
This signal is sent out to determine whether an event can be taken live. If you want to
|
||||||
prevent the event from going live, return a string that will be displayed to the user
|
prevent the event from going live, return an error message to display to the user (either
|
||||||
as the error message. If you don't, your receiver should return ``None``.
|
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't,
|
||||||
|
your receiver should return ``None``.
|
||||||
|
|
||||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.html import conditional_escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
@@ -44,7 +45,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
|
|||||||
_html = []
|
_html = []
|
||||||
for receiver, response in signal.send(event, **kwargs):
|
for receiver, response in signal.send(event, **kwargs):
|
||||||
if response:
|
if response:
|
||||||
_html.append(response)
|
_html.append(conditional_escape(response))
|
||||||
return mark_safe("".join(_html))
|
return mark_safe("".join(_html))
|
||||||
|
|
||||||
|
|
||||||
@@ -63,5 +64,5 @@ def signal(signame: str, request, **kwargs):
|
|||||||
_html = []
|
_html = []
|
||||||
for receiver, response in signal.send(request, **kwargs):
|
for receiver, response in signal.send(request, **kwargs):
|
||||||
if response:
|
if response:
|
||||||
_html.append(response)
|
_html.append(conditional_escape(response))
|
||||||
return mark_safe("".join(_html))
|
return mark_safe("".join(_html))
|
||||||
|
|||||||
@@ -1905,12 +1905,6 @@ class QuickSetupForm(I18nForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||||
)
|
)
|
||||||
contact_url = forms.URLField(
|
|
||||||
label=_("Contact URL"),
|
|
||||||
required=False,
|
|
||||||
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
|
|
||||||
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
|
|
||||||
)
|
|
||||||
total_quota = forms.IntegerField(
|
total_quota = forms.IntegerField(
|
||||||
label=_("Total capacity"),
|
label=_("Total capacity"),
|
||||||
min_value=0,
|
min_value=0,
|
||||||
|
|||||||
@@ -32,16 +32,20 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
import csv
|
import csv
|
||||||
from collections import namedtuple
|
from collections import Counter, namedtuple
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import EmailValidator
|
from django.core.validators import EmailValidator
|
||||||
|
from django.db.models import Count, F, Max
|
||||||
from django.db.models.functions import Upper
|
from django.db.models.functions import Upper
|
||||||
|
from django.forms.utils import ErrorDict
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.timezone import now
|
||||||
|
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||||
from django_scopes.forms import SafeModelChoiceField
|
from django_scopes.forms import SafeModelChoiceField
|
||||||
|
|
||||||
from pretix.base.email import get_available_placeholders
|
from pretix.base.email import get_available_placeholders
|
||||||
@@ -50,7 +54,10 @@ from pretix.base.forms import (
|
|||||||
)
|
)
|
||||||
from pretix.base.forms.widgets import format_placeholders_help_text
|
from pretix.base.forms.widgets import format_placeholders_help_text
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import Item, Voucher
|
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
|
||||||
|
from pretix.base.models.vouchers import VoucherBulkData
|
||||||
|
from pretix.base.services.locking import lock_objects
|
||||||
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||||||
from pretix.control.signals import voucher_form_validation
|
from pretix.control.signals import voucher_form_validation
|
||||||
@@ -105,20 +112,22 @@ class VoucherForm(I18nModelForm):
|
|||||||
except Item.DoesNotExist:
|
except Item.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
if not self.event and self.instance:
|
||||||
|
self.event = self.instance.event
|
||||||
|
|
||||||
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
|
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
|
||||||
'event': instance.event.slug,
|
'event': self.event.slug,
|
||||||
'organizer': instance.event.organizer.slug,
|
'organizer': self.event.organizer.slug,
|
||||||
})
|
})
|
||||||
|
|
||||||
if instance.event.has_subevents:
|
if self.event.has_subevents:
|
||||||
self.fields['subevent'].queryset = instance.event.subevents.all()
|
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||||
self.fields['subevent'].widget = Select2(
|
self.fields['subevent'].widget = Select2(
|
||||||
attrs={
|
attrs={
|
||||||
'data-model-select2': 'event',
|
'data-model-select2': 'event',
|
||||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||||
'event': instance.event.slug,
|
'event': self.event.slug,
|
||||||
'organizer': instance.event.organizer.slug,
|
'organizer': self.event.organizer.slug,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -128,18 +137,19 @@ class VoucherForm(I18nModelForm):
|
|||||||
del self.fields['subevent']
|
del self.fields['subevent']
|
||||||
|
|
||||||
choices = []
|
choices = []
|
||||||
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
|
prefix = (self.prefix + '-') if self.prefix else ''
|
||||||
iv = self.data.get('itemvar') or initial.get('itemvar', '')
|
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
|
||||||
|
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
|
||||||
if iv.startswith('q-'):
|
if iv.startswith('q-'):
|
||||||
q = self.instance.event.quotas.get(pk=iv[2:])
|
q = self.event.quotas.get(pk=iv[2:])
|
||||||
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
|
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
|
||||||
elif '-' in iv:
|
elif '-' in iv:
|
||||||
itemid, varid = iv.split('-')
|
itemid, varid = iv.split('-')
|
||||||
i = self.instance.event.items.get(pk=itemid)
|
i = self.event.items.get(pk=itemid)
|
||||||
v = i.variations.get(pk=varid)
|
v = i.variations.get(pk=varid)
|
||||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
||||||
elif iv:
|
elif iv:
|
||||||
i = self.instance.event.items.get(pk=iv)
|
i = self.event.items.get(pk=iv)
|
||||||
if i.variations.exists():
|
if i.variations.exists():
|
||||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
|
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
|
||||||
else:
|
else:
|
||||||
@@ -150,8 +160,8 @@ class VoucherForm(I18nModelForm):
|
|||||||
attrs={
|
attrs={
|
||||||
'data-model-select2': 'generic',
|
'data-model-select2': 'generic',
|
||||||
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
|
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
|
||||||
'event': instance.event.slug,
|
'event': self.event.slug,
|
||||||
'organizer': instance.event.organizer.slug,
|
'organizer': self.event.organizer.slug,
|
||||||
}),
|
}),
|
||||||
'data-placeholder': _('All products')
|
'data-placeholder': _('All products')
|
||||||
}
|
}
|
||||||
@@ -159,7 +169,7 @@ class VoucherForm(I18nModelForm):
|
|||||||
self.fields['itemvar'].required = False
|
self.fields['itemvar'].required = False
|
||||||
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
|
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
|
||||||
|
|
||||||
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
|
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
|
||||||
self.fields['seat'] = forms.CharField(
|
self.fields['seat'] = forms.CharField(
|
||||||
label=_("Specific seat ID"),
|
label=_("Specific seat ID"),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -169,40 +179,45 @@ class VoucherForm(I18nModelForm):
|
|||||||
help_text=str(self.instance.seat) if self.instance.seat else '',
|
help_text=str(self.instance.seat) if self.instance.seat else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def parse_itemvar(self, data):
|
||||||
|
try:
|
||||||
|
itemid = quotaid = None
|
||||||
|
iv = data.get('itemvar', '')
|
||||||
|
if iv.startswith('q-'):
|
||||||
|
quotaid = iv[2:]
|
||||||
|
elif '-' in iv:
|
||||||
|
itemid, varid = iv.split('-')
|
||||||
|
elif iv:
|
||||||
|
itemid, varid = iv, None
|
||||||
|
else:
|
||||||
|
itemid, varid = None, None
|
||||||
|
|
||||||
|
if itemid:
|
||||||
|
item = self.event.items.get(pk=itemid)
|
||||||
|
if varid:
|
||||||
|
variation = item.variations.get(pk=varid)
|
||||||
|
else:
|
||||||
|
variation = None
|
||||||
|
quota = None
|
||||||
|
elif quotaid:
|
||||||
|
quota = self.event.quotas.get(pk=quotaid)
|
||||||
|
item = None
|
||||||
|
variation = None
|
||||||
|
else:
|
||||||
|
quota = None
|
||||||
|
item = None
|
||||||
|
variation = None
|
||||||
|
|
||||||
|
return (item, variation, quota)
|
||||||
|
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
raise ValidationError(_("Invalid product selected."))
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
data = super().clean()
|
data = super().clean()
|
||||||
|
|
||||||
if not self._errors:
|
if not self._errors:
|
||||||
try:
|
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
|
||||||
itemid = quotaid = None
|
|
||||||
iv = self.data.get('itemvar', '')
|
|
||||||
if iv.startswith('q-'):
|
|
||||||
quotaid = iv[2:]
|
|
||||||
elif '-' in iv:
|
|
||||||
itemid, varid = iv.split('-')
|
|
||||||
elif iv:
|
|
||||||
itemid, varid = iv, None
|
|
||||||
else:
|
|
||||||
itemid, varid = None, None
|
|
||||||
|
|
||||||
if itemid:
|
|
||||||
self.instance.item = self.instance.event.items.get(pk=itemid)
|
|
||||||
if varid:
|
|
||||||
self.instance.variation = self.instance.item.variations.get(pk=varid)
|
|
||||||
else:
|
|
||||||
self.instance.variation = None
|
|
||||||
self.instance.quota = None
|
|
||||||
elif quotaid:
|
|
||||||
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
|
|
||||||
self.instance.item = None
|
|
||||||
self.instance.variation = None
|
|
||||||
else:
|
|
||||||
self.instance.quota = None
|
|
||||||
self.instance.item = None
|
|
||||||
self.instance.variation = None
|
|
||||||
|
|
||||||
except ObjectDoesNotExist:
|
|
||||||
raise ValidationError(_("Invalid product selected."))
|
|
||||||
|
|
||||||
if 'codes' in data:
|
if 'codes' in data:
|
||||||
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
|
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
|
||||||
@@ -214,7 +229,7 @@ class VoucherForm(I18nModelForm):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
Voucher.clean_item_properties(
|
Voucher.clean_item_properties(
|
||||||
data, self.instance.event,
|
data, self.event,
|
||||||
self.instance.quota, self.instance.item, self.instance.variation,
|
self.instance.quota, self.instance.item, self.instance.variation,
|
||||||
seats_given=data.get('seat') or data.get('seats'),
|
seats_given=data.get('seat') or data.get('seats'),
|
||||||
block_quota=data.get('block_quota')
|
block_quota=data.get('block_quota')
|
||||||
@@ -234,7 +249,7 @@ class VoucherForm(I18nModelForm):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
Voucher.clean_subevent(
|
Voucher.clean_subevent(
|
||||||
data, self.instance.event
|
data, self.event
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise ValidationError({"subevent": e.message})
|
raise ValidationError({"subevent": e.message})
|
||||||
@@ -250,19 +265,19 @@ class VoucherForm(I18nModelForm):
|
|||||||
if check_quota:
|
if check_quota:
|
||||||
Voucher.clean_quota_check(
|
Voucher.clean_quota_check(
|
||||||
data, cnt, self.initial_instance_data,
|
data, cnt, self.initial_instance_data,
|
||||||
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
|
self.event, self.instance.quota, self.instance.item, self.instance.variation
|
||||||
)
|
)
|
||||||
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
|
Voucher.clean_voucher_code(data, self.event, self.instance.pk)
|
||||||
if 'seat' in self.fields:
|
if 'seat' in self.fields:
|
||||||
if data.get('seat'):
|
if data.get('seat'):
|
||||||
self.instance.seat = Voucher.clean_seat_id(
|
self.instance.seat = Voucher.clean_seat_id(
|
||||||
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
|
data, self.instance.item, self.instance.quota, self.event, self.instance.pk
|
||||||
)
|
)
|
||||||
self.instance.item = self.instance.seat.product
|
self.instance.item = self.instance.seat.product
|
||||||
else:
|
else:
|
||||||
self.instance.seat = None
|
self.instance.seat = None
|
||||||
|
|
||||||
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
|
voucher_form_validation.send(sender=self.event, form=self, data=data)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -270,6 +285,215 @@ class VoucherForm(I18nModelForm):
|
|||||||
return super().save(commit)
|
return super().save(commit)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherBulkEditForm(VoucherForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.mixed_values = kwargs.pop('mixed_values')
|
||||||
|
self.queryset = kwargs.pop('queryset')
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
del self.fields["code"]
|
||||||
|
self.fields.pop("seat", None)
|
||||||
|
|
||||||
|
def is_bulk_checked(self, fieldname):
|
||||||
|
return self.prefix + fieldname in self.data.getlist('_bulk')
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
|
||||||
|
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
|
||||||
|
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
|
||||||
|
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
|
||||||
|
# add-on product. However, we need to validate everything that we don't want violated in the database.
|
||||||
|
data = super(VoucherForm, self).clean()
|
||||||
|
|
||||||
|
if self.is_bulk_checked("itemvar"):
|
||||||
|
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
|
||||||
|
|
||||||
|
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||||
|
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
|
||||||
|
if data["max_usages"] < max_redeemed:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
|
||||||
|
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
|
||||||
|
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
|
||||||
|
|
||||||
|
# Check diff on product and quota usage based on old groups of vouchers
|
||||||
|
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
|
||||||
|
quota_diff = Counter()
|
||||||
|
|
||||||
|
current_vouchers = self.queryset.order_by().values(
|
||||||
|
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
|
||||||
|
"allow_ignore_quota",
|
||||||
|
).annotate(c=Count("*"))
|
||||||
|
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
|
||||||
|
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
|
||||||
|
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
|
||||||
|
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
|
||||||
|
|
||||||
|
for current in current_vouchers:
|
||||||
|
bulk_count = current.pop('c')
|
||||||
|
current = VoucherBulkData(**current)
|
||||||
|
# Get quotas that are currently used
|
||||||
|
if current.item:
|
||||||
|
current.item = item_cache[current.item]
|
||||||
|
if current.variation:
|
||||||
|
current.variation = var_cache[current.variation]
|
||||||
|
if current.quota:
|
||||||
|
current.quota = quota_cache[current.quota]
|
||||||
|
if current.subevent:
|
||||||
|
current.subevent = subevent_cache[current.subevent]
|
||||||
|
|
||||||
|
old_quotas = Voucher.clean_quota_get_ignored(current)
|
||||||
|
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
|
||||||
|
|
||||||
|
# Predict state after change
|
||||||
|
after_change = copy.copy(current)
|
||||||
|
if self.is_bulk_checked("itemvar") and "itemvar" in data:
|
||||||
|
after_change.item = data["item"]
|
||||||
|
after_change.variation = data["variation"]
|
||||||
|
after_change.quota = data["quota"]
|
||||||
|
if self.is_bulk_checked("subevent") and "subevent" in data:
|
||||||
|
after_change.subevent = data["subevent"]
|
||||||
|
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||||
|
after_change.max_usages = data["max_usages"]
|
||||||
|
if self.is_bulk_checked("block_quota") and "block_quota" in data:
|
||||||
|
after_change.block_quota = data["block_quota"]
|
||||||
|
if self.is_bulk_checked("valid_until") and "valid_until" in data:
|
||||||
|
after_change.valid_until = data["valid_until"]
|
||||||
|
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
|
||||||
|
after_change.allow_ignore_quota = data["allow_ignore_quota"]
|
||||||
|
|
||||||
|
if after_change.quota and self.event.has_subevents and not after_change.subevent:
|
||||||
|
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
|
||||||
|
|
||||||
|
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
|
||||||
|
raise ValidationError(_("The selected quota does not match the selected subevent."))
|
||||||
|
|
||||||
|
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
|
||||||
|
raise ValidationError(
|
||||||
|
_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||||
|
|
||||||
|
if after_change.block_quota and not after_change.item and not after_change.quota:
|
||||||
|
raise ValidationError(
|
||||||
|
_('You need to select a specific product or quota if this voucher should reserve '
|
||||||
|
'tickets.')
|
||||||
|
)
|
||||||
|
|
||||||
|
if after_change.allow_ignore_quota:
|
||||||
|
# todo: is this the most useful way to do this?
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_quotas = Voucher.clean_quota_get_ignored(after_change)
|
||||||
|
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
|
||||||
|
|
||||||
|
if new_quotas != old_quotas or new_amount != old_amount:
|
||||||
|
for q in old_quotas:
|
||||||
|
quota_diff[q] -= old_amount
|
||||||
|
for q in new_quotas:
|
||||||
|
quota_diff[q] += new_amount
|
||||||
|
|
||||||
|
if any(v > 0 for q, v in quota_diff.items()):
|
||||||
|
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
|
||||||
|
qa = QuotaAvailability(count_waitinglist=False)
|
||||||
|
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
|
||||||
|
qa.compute()
|
||||||
|
|
||||||
|
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
|
||||||
|
for q, required in quota_diff.items() if required > 0):
|
||||||
|
raise ValidationError(_(
|
||||||
|
'There is no sufficient quota available to perform this change.'
|
||||||
|
))
|
||||||
|
|
||||||
|
has_seat = self.queryset.filter(seat__isnull=False).exists()
|
||||||
|
if has_seat:
|
||||||
|
if self.is_bulk_checked("max_usages"):
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
|
||||||
|
'is assigned a seat.'
|
||||||
|
))
|
||||||
|
if self.is_bulk_checked("subevent"):
|
||||||
|
raise ValidationError(pgettext_lazy(
|
||||||
|
'subevent',
|
||||||
|
'Changing the date in bulk is not supported if any of the selected vouchers '
|
||||||
|
'is assigned a seat.'
|
||||||
|
))
|
||||||
|
if self.is_bulk_checked("itemvar") and data["quota"]:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'Changing the product to a quota is not supported if any of the selected vouchers '
|
||||||
|
'is assigned a seat.'
|
||||||
|
))
|
||||||
|
|
||||||
|
if self.is_bulk_checked("valid_until"):
|
||||||
|
if data["valid_until"] is None or data["valid_until"] >= now():
|
||||||
|
currently_not_blocked_seats = self.queryset.filter(
|
||||||
|
seat__isnull=False,
|
||||||
|
max_usages__gt=F("redeemed"),
|
||||||
|
valid_until__lt=now(),
|
||||||
|
)
|
||||||
|
if self.event.has_subevents:
|
||||||
|
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
|
||||||
|
for se in subevents:
|
||||||
|
conflicts = currently_not_blocked_seats.filter(
|
||||||
|
subevent=se
|
||||||
|
).exclude(
|
||||||
|
seat_id__in=se.free_seats().values("pk")
|
||||||
|
)
|
||||||
|
if conflicts:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||||
|
'still available'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
conflicts = currently_not_blocked_seats.exclude(
|
||||||
|
seat_id__in=self.event.free_seats().values("pk")
|
||||||
|
)
|
||||||
|
if conflicts:
|
||||||
|
raise ValidationError(_(
|
||||||
|
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||||
|
'still available'
|
||||||
|
))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
objs = list(self.queryset)
|
||||||
|
fields = set()
|
||||||
|
|
||||||
|
check_map = {
|
||||||
|
'price_mode': '__price',
|
||||||
|
'value': '__price',
|
||||||
|
}
|
||||||
|
for k in self.fields:
|
||||||
|
if not self.is_bulk_checked(check_map.get(k, k)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if k == 'itemvar':
|
||||||
|
fields.add("item")
|
||||||
|
fields.add("variation")
|
||||||
|
fields.add("quota")
|
||||||
|
else:
|
||||||
|
fields.add(k)
|
||||||
|
for obj in objs:
|
||||||
|
if k == 'itemvar':
|
||||||
|
obj.item = self.cleaned_data["item"]
|
||||||
|
obj.variation = self.cleaned_data["variation"]
|
||||||
|
obj.quota = self.cleaned_data["quota"]
|
||||||
|
else:
|
||||||
|
setattr(obj, k, self.cleaned_data[k])
|
||||||
|
|
||||||
|
fields = [f for f in fields if f != 'itemvars']
|
||||||
|
if fields:
|
||||||
|
Voucher.objects.bulk_update(objs, fields, 200)
|
||||||
|
|
||||||
|
def full_clean(self):
|
||||||
|
if len(self.data) == 0:
|
||||||
|
# form wasn't submitted
|
||||||
|
self._errors = ErrorDict()
|
||||||
|
return
|
||||||
|
super().full_clean()
|
||||||
|
|
||||||
|
def _post_clean(self):
|
||||||
|
pass # skip model-level clean
|
||||||
|
|
||||||
|
|
||||||
class VoucherBulkForm(VoucherForm):
|
class VoucherBulkForm(VoucherForm):
|
||||||
codes = forms.CharField(
|
codes = forms.CharField(
|
||||||
widget=forms.Textarea,
|
widget=forms.Textarea,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from urllib.parse import quote, urljoin, urlparse
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
|
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
|
||||||
from django.contrib.auth.views import redirect_to_login
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.http import Http404
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, resolve_url
|
from django.shortcuts import get_object_or_404, resolve_url
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import get_script_prefix, resolve, reverse
|
from django.urls import get_script_prefix, resolve, reverse
|
||||||
@@ -98,6 +98,8 @@ class PermissionMiddleware:
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _login_redirect(self, request):
|
def _login_redirect(self, request):
|
||||||
|
from django.contrib.auth.views import redirect_to_login
|
||||||
|
|
||||||
# Taken from django/contrib/auth/decorators.py
|
# Taken from django/contrib/auth/decorators.py
|
||||||
path = request.build_absolute_uri()
|
path = request.build_absolute_uri()
|
||||||
# urlparse chokes on lazy objects in Python 3, force to str
|
# urlparse chokes on lazy objects in Python 3, force to str
|
||||||
@@ -110,10 +112,21 @@ class PermissionMiddleware:
|
|||||||
if ((not login_scheme or login_scheme == current_scheme) and
|
if ((not login_scheme or login_scheme == current_scheme) and
|
||||||
(not login_netloc or login_netloc == current_netloc)):
|
(not login_netloc or login_netloc == current_netloc)):
|
||||||
path = request.get_full_path()
|
path = request.get_full_path()
|
||||||
from django.contrib.auth.views import redirect_to_login
|
|
||||||
|
|
||||||
return redirect_to_login(
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
|
||||||
|
# the XMLHttpRequest is unable to detect redirects.
|
||||||
|
return HttpResponse(
|
||||||
|
"Authentication required",
|
||||||
|
status=401,
|
||||||
|
headers={
|
||||||
|
# Appending ?next= is handled by client, because it should be the top-level context url,
|
||||||
|
# not the URL called in the background
|
||||||
|
"X-Login-Url": resolved_login_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
url = resolve(request.path_info)
|
url = resolve(request.path_info)
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ from pretix.base.signals import (
|
|||||||
html_page_start = GlobalSignal()
|
html_page_start = GlobalSignal()
|
||||||
"""
|
"""
|
||||||
This signal allows you to put code in the beginning of the main page for every
|
This signal allows you to put code in the beginning of the main page for every
|
||||||
page in the backend. You are expected to return HTML.
|
page in the backend. You are expected to return a SafeString containing HTML, or
|
||||||
|
a string that will be HTML-escaped.
|
||||||
|
|
||||||
The ``sender`` keyword argument will contain the request.
|
The ``sender`` keyword argument will contain the request.
|
||||||
"""
|
"""
|
||||||
@@ -129,7 +130,7 @@ event_dashboard_top = EventPluginSignal()
|
|||||||
Arguments: 'request'
|
Arguments: 'request'
|
||||||
|
|
||||||
This signal is sent out to include custom HTML in the top part of the the event dashboard.
|
This signal is sent out to include custom HTML in the top part of the the event dashboard.
|
||||||
Receivers should return HTML.
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
An additional keyword argument ``subevent`` *can* contain a sub-event.
|
An additional keyword argument ``subevent`` *can* contain a sub-event.
|
||||||
@@ -172,6 +173,7 @@ Arguments: 'form'
|
|||||||
|
|
||||||
This signal allows you to add additional HTML to the form that is used for modifying vouchers.
|
This signal allows you to add additional HTML to the form that is used for modifying vouchers.
|
||||||
You receive the form object in the ``form`` keyword argument.
|
You receive the form object in the ``form`` keyword argument.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -209,6 +211,7 @@ Arguments: 'quota'
|
|||||||
|
|
||||||
This signal allows you to append HTML to a Quota's detail view. You receive the
|
This signal allows you to append HTML to a Quota's detail view. You receive the
|
||||||
quota as argument in the ``quota`` keyword argument.
|
quota as argument in the ``quota`` keyword argument.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -219,6 +222,7 @@ Arguments: 'subevent'
|
|||||||
|
|
||||||
This signal allows you to append HTML to a SubEvent's detail view. You receive the
|
This signal allows you to append HTML to a SubEvent's detail view. You receive the
|
||||||
subevent as argument in the ``subevent`` keyword argument.
|
subevent as argument in the ``subevent`` keyword argument.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -265,7 +269,8 @@ order_info = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``request``
|
Arguments: ``order``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on the order detail page
|
This signal is sent out to display additional information on the order detail page.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
Additionally, the argument ``order`` and ``request`` are available.
|
Additionally, the argument ``order`` and ``request`` are available.
|
||||||
@@ -275,7 +280,8 @@ order_approve_info = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``request``
|
Arguments: ``order``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on the order approve page
|
This signal is sent out to display additional information on the order approve page.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
Additionally, the argument ``order`` and ``request`` are available.
|
Additionally, the argument ``order`` and ``request`` are available.
|
||||||
@@ -286,6 +292,7 @@ order_position_buttons = EventPluginSignal()
|
|||||||
Arguments: ``order``, ``position``, ``request``
|
Arguments: ``order``, ``position``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional buttons for a single position of an order.
|
This signal is sent out to display additional buttons for a single position of an order.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
Additionally, the argument ``order`` and ``request`` are available.
|
Additionally, the argument ``order`` and ``request`` are available.
|
||||||
@@ -315,6 +322,7 @@ Arguments: 'request'
|
|||||||
|
|
||||||
This signal is sent out to include template snippets on the settings page of an event
|
This signal is sent out to include template snippets on the settings page of an event
|
||||||
that allows generating a pretix Widget code.
|
that allows generating a pretix Widget code.
|
||||||
|
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
A second keyword argument ``request`` will contain the request object.
|
A second keyword argument ``request`` will contain the request object.
|
||||||
|
|||||||
@@ -56,5 +56,4 @@
|
|||||||
</form>
|
</form>
|
||||||
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
|
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
|
||||||
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
|
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
|
||||||
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load compress %}
|
{% load compress %}
|
||||||
|
{% load escapejson %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form class="form-signin" action="" method="post" id="webauthn-form">
|
<form class="form-signin" action="" method="post" id="webauthn-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -30,8 +31,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% if jsondata %}
|
{% if jsondata %}
|
||||||
<script type="text/json" id="webauthn-login">
|
<script type="text/json" id="webauthn-login">
|
||||||
{{ jsondata|safe }}
|
{{ jsondata|escapejson }}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% compress js %}
|
{% compress js %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for issue in issues %}
|
{% for issue in issues %}
|
||||||
<li>{{ issue|safe }}</li>
|
<li>{{ issue }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
{% for issue in issues %}
|
{% for issue in issues %}
|
||||||
<li>{{ issue|safe }}</li>
|
<li>{{ issue }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -193,7 +193,6 @@
|
|||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
{% bootstrap_field form.contact_mail layout="control" %}
|
{% bootstrap_field form.contact_mail layout="control" %}
|
||||||
{% bootstrap_field form.contact_url layout="control" %}
|
|
||||||
{% bootstrap_field form.imprint_url layout="control" %}
|
{% bootstrap_field form.imprint_url layout="control" %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% load money %}
|
{% load money %}
|
||||||
|
{% load wrap_in %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% trans "Cancel order" %}
|
{% trans "Cancel order" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
{% if form.cancellation_fee %}
|
{% if form.cancellation_fee %}
|
||||||
{% if fee %}
|
{% if fee %}
|
||||||
{% with fee|money:request.event.currency as f %}
|
{% with fee|money:request.event.currency as f %}
|
||||||
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
|
<p>{% blocktrans trimmed with fee=f|wrap_in:"strong" %}
|
||||||
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
|
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
|
||||||
order, but for a cancellation performed by you, you need to set the cancellation fee here:
|
order, but for a cancellation performed by you, you need to set the cancellation fee here:
|
||||||
{% endblocktrans %}</p>
|
{% endblocktrans %}</p>
|
||||||
|
|||||||
@@ -903,7 +903,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="1"></td>
|
<td colspan="1"></td>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
{{ p.html_info|safe }}
|
{{ p.html_info }}
|
||||||
{% if staff_session %}
|
{% if staff_session %}
|
||||||
<p>
|
<p>
|
||||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||||
@@ -1018,7 +1018,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if r.html_info %}
|
{% if r.html_info %}
|
||||||
{{ r.html_info|safe }}
|
{{ r.html_info }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if staff_session %}
|
{% if staff_session %}
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
|
{% load escapejson %}
|
||||||
{% block inner %}
|
{% block inner %}
|
||||||
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>
|
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
{% trans "Open the app that you want to connect and optionally reset it to the original state." %}
|
{% trans "Open the app that you want to connect and optionally reset it to the original state." %}
|
||||||
</li>
|
</li>
|
||||||
<li>{% trans "Scan the following configuration code:" %}<br><br>
|
<li>{% trans "Scan the following configuration code:" %}<br><br>
|
||||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script><br>
|
||||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||||
<br>
|
<br>
|
||||||
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
|
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "pretixcontrol/base.html" %}
|
{% extends "pretixcontrol/base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
|
{% load escapejson %}
|
||||||
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
|
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{% trans "Add a new account to the app by scanning the following barcode:" %}
|
{% trans "Add a new account to the app by scanning the following barcode:" %}
|
||||||
<div class="qrcode-canvas" data-qrdata="#qrdata"></div>
|
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script>
|
||||||
<p>
|
<p>
|
||||||
<a data-toggle="collapse" href="#no_scan">
|
<a data-toggle="collapse" href="#no_scan">
|
||||||
{% trans "Can't scan the barcode?" %}
|
{% trans "Can't scan the barcode?" %}
|
||||||
@@ -81,9 +82,4 @@
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<script type="text/json" id="qrdata">
|
|
||||||
{{ qrdata|safe }}
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load compress %}
|
{% load compress %}
|
||||||
|
{% load escapejson %}
|
||||||
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
|
{% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
<h1>{% trans "Add a two-factor authentication device" %}</h1>
|
||||||
@@ -26,9 +27,7 @@
|
|||||||
{% trans "Device registration failed." %}
|
{% trans "Device registration failed." %}
|
||||||
</div>
|
</div>
|
||||||
<script type="text/json" id="webauthn-enroll">
|
<script type="text/json" id="webauthn-enroll">
|
||||||
{{ jsondata|safe }}
|
{{ jsondata|escapejson }}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% compress js %}
|
{% compress js %}
|
||||||
<script type="text/javascript" src="{% static "pretixcontrol/js/base64js.js" %}"></script>
|
<script type="text/javascript" src="{% static "pretixcontrol/js/base64js.js" %}"></script>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% load bootstrap3 %}
|
{% load bootstrap3 %}
|
||||||
{% load compress %}
|
{% load compress %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load escapejson %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form class="form-signin" id="webauthn-form" action="" method="post">
|
<form class="form-signin" id="webauthn-form" action="" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
|
|
||||||
{% if jsondata %}
|
{% if jsondata %}
|
||||||
<script type="text/json" id="webauthn-login">
|
<script type="text/json" id="webauthn-login">
|
||||||
{{ jsondata|safe }}
|
{{ jsondata|escapejson }}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% compress js %}
|
{% compress js %}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "pretixcontrol/items/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load eventsignal %}
|
||||||
|
{% load eventurl %}
|
||||||
|
{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
|
||||||
|
{% block inside %}
|
||||||
|
<h1>
|
||||||
|
{% trans "Change multiple vouchers" %}
|
||||||
|
<small>
|
||||||
|
{% blocktrans trimmed with number=vouchers.count %}
|
||||||
|
{{ number }} selected
|
||||||
|
{% endblocktrans %}
|
||||||
|
</small>
|
||||||
|
</h1>
|
||||||
|
<form action="" method="post" class="form-horizontal">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="hidden">
|
||||||
|
{% for v in vouchers %}
|
||||||
|
<input type="hidden" name="voucher" value="{{ v.pk }}">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% bootstrap_form_errors form %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "Voucher details" %}</legend>
|
||||||
|
{% bootstrap_field form.max_usages layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.valid_until layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.itemvar layout="bulkedit" %}
|
||||||
|
|
||||||
|
<div class="bulk-edit-field-group">
|
||||||
|
<label class="field-toggle">
|
||||||
|
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__price" {% if form.prefix|add:"__price" in bulk_selected %}checked{% endif %}>
|
||||||
|
{% trans "change" context "form_bulk" %}
|
||||||
|
</label>
|
||||||
|
<div class="field-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
|
||||||
|
<div class="col-md-5">
|
||||||
|
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
{% bootstrap_field form.value show_label=False form_group_class="" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-md-9 col-md-offset-3">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
If you choose "any product" for a specific quota and choose to reserve quota for this
|
||||||
|
voucher above, the product can still be unavailable to the voucher holder if another quota
|
||||||
|
associated with the product is sold out!
|
||||||
|
{% endblocktrans %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if form.subevent %}
|
||||||
|
{% bootstrap_field form.subevent layout="bulkedit" %}
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>{% trans "Advanced settings" %}</legend>
|
||||||
|
{% bootstrap_field form.block_quota layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.allow_ignore_quota layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.min_usages layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.budget addon_after=request.event.currency layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.tag layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.comment layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.show_hidden_items layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.all_addons_included layout="bulkedit" %}
|
||||||
|
{% bootstrap_field form.all_bundles_included layout="bulkedit" %}
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-group submit-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
|
{% trans "Save" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -99,6 +99,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
|
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% for field in filter_form %}
|
||||||
|
{{ field.as_hidden }}
|
||||||
|
{% endfor %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-quotas">
|
<table class="table table-hover table-quotas">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -144,6 +147,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if "event.vouchers:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
|
||||||
|
<tr class="table-select-all warning hidden">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
|
||||||
|
</td>
|
||||||
|
<td colspan="5">
|
||||||
|
<label for="__all">
|
||||||
|
{% trans "Select all results on other pages as well" %}
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for v in vouchers %}
|
{% for v in vouchers %}
|
||||||
@@ -211,6 +226,10 @@
|
|||||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
{% trans "Delete selected" %}
|
{% trans "Delete selected" %}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
|
||||||
|
formaction="{% url "control:event.vouchers.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
|
||||||
|
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -49,11 +49,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>
|
<strong>
|
||||||
{% if t.tag %}
|
{% if t.tag %}
|
||||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '"'|add:t.tag|add:'"'|urlencode }}">
|
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?filter-tag={{ '"'|add:t.tag|add:'"'|urlencode }}">
|
||||||
{{ t.tag }}
|
{{ t.tag }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '<>'|urlencode }}">
|
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?filter-tag={{ '<>'|urlencode }}">
|
||||||
{% trans "Empty tag" %}
|
{% trans "Empty tag" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ urlpatterns = [
|
|||||||
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
|
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
|
||||||
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
|
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
|
||||||
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
|
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
|
||||||
|
re_path(r'^vouchers/bulk_edit$', vouchers.VoucherBulkUpdateView.as_view(), name='event.vouchers.bulkedit'),
|
||||||
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
|
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
|
||||||
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
|
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
|
||||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
|
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
|
||||||
|
|||||||
@@ -1428,11 +1428,16 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
|
|||||||
form.instance.custom_rules = json.dumps([
|
form.instance.custom_rules = json.dumps([
|
||||||
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
|
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
|
||||||
], cls=I18nJSONEncoder)
|
], cls=I18nJSONEncoder)
|
||||||
if form.has_changed():
|
if form.has_changed() or self.formset.has_changed():
|
||||||
|
change_data = {
|
||||||
|
k: form.cleaned_data.get(k) for k in form.changed_data
|
||||||
|
}
|
||||||
|
if self.formset.has_changed():
|
||||||
|
change_data["custom_rules"] = [
|
||||||
|
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
|
||||||
|
]
|
||||||
self.object.log_action(
|
self.object.log_action(
|
||||||
'pretix.event.taxrule.changed', user=self.request.user, data={
|
'pretix.event.taxrule.changed', user=self.request.user, data=change_data
|
||||||
k: form.cleaned_data.get(k) for k in form.changed_data
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|||||||
@@ -551,10 +551,10 @@ class OrderDetail(OrderView):
|
|||||||
ctx['refunds'] = self.order.refunds.select_related('payment').order_by('-created')
|
ctx['refunds'] = self.order.refunds.select_related('payment').order_by('-created')
|
||||||
for p in ctx['payments']:
|
for p in ctx['payments']:
|
||||||
if p.payment_provider:
|
if p.payment_provider:
|
||||||
p.html_info = (p.payment_provider.payment_control_render(self.request, p) or "").strip()
|
p.html_info = p.payment_provider.payment_control_render(self.request, p) or ""
|
||||||
for r in ctx['refunds']:
|
for r in ctx['refunds']:
|
||||||
if r.payment_provider:
|
if r.payment_provider:
|
||||||
r.html_info = (r.payment_provider.refund_control_render(self.request, r) or "").strip()
|
r.html_info = r.payment_provider.refund_control_render(self.request, r) or ""
|
||||||
ctx['invoices'] = list(self.order.invoices.all().select_related('event'))
|
ctx['invoices'] = list(self.order.invoices.all().select_related('event'))
|
||||||
ctx['comment_form'] = CommentForm(initial={
|
ctx['comment_form'] = CommentForm(initial={
|
||||||
'comment': self.order.comment,
|
'comment': self.order.comment,
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ import bleach
|
|||||||
from defusedcsv import csv
|
from defusedcsv import csv
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import (
|
||||||
|
BadRequest, PermissionDenied, ValidationError,
|
||||||
|
)
|
||||||
from django.db import connection, transaction
|
from django.db import connection, transaction
|
||||||
from django.db.models import Exists, OuterRef, Sum
|
from django.db.models import Count, Exists, OuterRef, Sum
|
||||||
from django.http import (
|
from django.http import (
|
||||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
|
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
|
||||||
JsonResponse,
|
JsonResponse,
|
||||||
@@ -55,7 +57,7 @@ from django.utils.safestring import mark_safe
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import (
|
from django.views.generic import (
|
||||||
CreateView, ListView, TemplateView, UpdateView, View,
|
CreateView, FormView, ListView, TemplateView, UpdateView, View,
|
||||||
)
|
)
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
@@ -70,7 +72,9 @@ from pretix.base.services.vouchers import vouchers_send
|
|||||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||||
from pretix.base.views.tasks import AsyncFormView
|
from pretix.base.views.tasks import AsyncFormView
|
||||||
from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm
|
from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm
|
||||||
from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
|
from pretix.control.forms.vouchers import (
|
||||||
|
VoucherBulkEditForm, VoucherBulkForm, VoucherForm,
|
||||||
|
)
|
||||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||||
from pretix.control.signals import voucher_form_class
|
from pretix.control.signals import voucher_form_class
|
||||||
from pretix.control.views import PaginationMixin
|
from pretix.control.views import PaginationMixin
|
||||||
@@ -80,7 +84,37 @@ from pretix.helpers.models import modelcopy
|
|||||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||||
|
|
||||||
|
|
||||||
class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
|
class VoucherQueryMixin:
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def request_data(self):
|
||||||
|
if self.request.method == "POST":
|
||||||
|
return self.request.POST
|
||||||
|
return self.request.GET
|
||||||
|
|
||||||
|
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = self.request.event.vouchers.exclude(
|
||||||
|
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
|
||||||
|
)
|
||||||
|
if 'voucher' in self.request_data and '__ALL' not in self.request_data:
|
||||||
|
qs = qs.filter(
|
||||||
|
id__in=self.request_data.getlist('voucher')
|
||||||
|
)
|
||||||
|
elif self.request.method == 'GET' or '__ALL' in self.request_data:
|
||||||
|
if self.filter_form.is_valid():
|
||||||
|
qs = self.filter_form.filter_qs(qs)
|
||||||
|
else:
|
||||||
|
raise BadRequest("No vouchers selected")
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return VoucherFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMixin, ListView):
|
||||||
model = Voucher
|
model = Voucher
|
||||||
context_object_name = 'vouchers'
|
context_object_name = 'vouchers'
|
||||||
template_name = 'pretixcontrol/vouchers/index.html'
|
template_name = 'pretixcontrol/vouchers/index.html'
|
||||||
@@ -88,25 +122,15 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
|
|||||||
|
|
||||||
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude(
|
return Voucher.annotate_budget_used(super().get_queryset().select_related(
|
||||||
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
|
|
||||||
).select_related(
|
|
||||||
'item', 'variation', 'seat'
|
'item', 'variation', 'seat'
|
||||||
))
|
))
|
||||||
if self.filter_form.is_valid():
|
|
||||||
qs = self.filter_form.filter_qs(qs)
|
|
||||||
|
|
||||||
return qs
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['filter_form'] = self.filter_form
|
ctx['filter_form'] = self.filter_form
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def filter_form(self):
|
|
||||||
return VoucherFilterForm(data=self.request.GET, event=self.request.event)
|
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if request.GET.get("download", "") == "yes":
|
if request.GET.get("download", "") == "yes":
|
||||||
return self._download_csv()
|
return self._download_csv()
|
||||||
@@ -293,6 +317,12 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView):
|
|||||||
f.disabled = True
|
f.disabled = True
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
return {
|
||||||
|
**super().get_form_kwargs(),
|
||||||
|
"event": self.request.event,
|
||||||
|
}
|
||||||
|
|
||||||
def get_object(self, queryset=None) -> VoucherForm:
|
def get_object(self, queryset=None) -> VoucherForm:
|
||||||
url = resolve(self.request.path_info)
|
url = resolve(self.request.path_info)
|
||||||
try:
|
try:
|
||||||
@@ -603,26 +633,21 @@ class VoucherRNG(EventPermissionRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class VoucherBulkAction(EventPermissionRequiredMixin, View):
|
class VoucherBulkAction(VoucherQueryMixin, EventPermissionRequiredMixin, View):
|
||||||
permission = 'event.vouchers:write'
|
permission = 'event.vouchers:write'
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def objects(self):
|
|
||||||
return self.request.event.vouchers.filter(
|
|
||||||
id__in=self.request.POST.getlist('voucher')
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if request.POST.get('action') == 'delete':
|
if request.POST.get('action') == 'delete':
|
||||||
return render(request, 'pretixcontrol/vouchers/delete_bulk.html', {
|
return render(request, 'pretixcontrol/vouchers/delete_bulk.html', {
|
||||||
'allowed': self.objects.filter(redeemed=0),
|
'allowed': self.get_queryset().filter(redeemed=0),
|
||||||
'forbidden': self.objects.exclude(redeemed=0),
|
'forbidden': self.get_queryset().exclude(redeemed=0),
|
||||||
})
|
})
|
||||||
elif request.POST.get('action') == 'delete_confirm':
|
elif request.POST.get('action') == 'delete_confirm':
|
||||||
log_entries = []
|
log_entries = []
|
||||||
to_delete = []
|
to_delete = []
|
||||||
for obj in self.objects:
|
to_update = []
|
||||||
|
for obj in self.get_queryset():
|
||||||
if obj.allow_delete():
|
if obj.allow_delete():
|
||||||
log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False))
|
log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False))
|
||||||
to_delete.append(obj.pk)
|
to_delete.append(obj.pk)
|
||||||
@@ -632,12 +657,14 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
|
|||||||
'bulk': True
|
'bulk': True
|
||||||
}, save=False))
|
}, save=False))
|
||||||
obj.max_usages = min(obj.redeemed, obj.max_usages)
|
obj.max_usages = min(obj.redeemed, obj.max_usages)
|
||||||
obj.save(update_fields=['max_usages'])
|
to_update.append(obj)
|
||||||
|
|
||||||
if to_delete:
|
if to_delete:
|
||||||
CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete()
|
CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete()
|
||||||
CartPosition.objects.filter(voucher_id__in=to_delete).delete()
|
CartPosition.objects.filter(voucher_id__in=to_delete).delete()
|
||||||
Voucher.objects.filter(pk__in=to_delete).delete()
|
Voucher.objects.filter(pk__in=to_delete).delete()
|
||||||
|
if to_update:
|
||||||
|
Voucher.objects.bulk_update(to_update, ['max_usages'])
|
||||||
|
|
||||||
LogEntry.bulk_create_and_postprocess(log_entries)
|
LogEntry.bulk_create_and_postprocess(log_entries)
|
||||||
messages.success(request, _('The selected vouchers have been deleted or disabled.'))
|
messages.success(request, _('The selected vouchers have been deleted or disabled.'))
|
||||||
@@ -648,3 +675,117 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
|
|||||||
'organizer': self.request.event.organizer.slug,
|
'organizer': self.request.event.organizer.slug,
|
||||||
'event': self.request.event.slug,
|
'event': self.request.event.slug,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherBulkUpdateView(VoucherQueryMixin, EventPermissionRequiredMixin, FormView):
|
||||||
|
template_name = 'pretixcontrol/vouchers/bulk_edit.html'
|
||||||
|
permission = 'event.vouchers:write'
|
||||||
|
context_object_name = 'vouchers'
|
||||||
|
form_class = VoucherBulkEditForm
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().prefetch_related(None).order_by()
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def is_submitted(self):
|
||||||
|
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
|
||||||
|
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
|
||||||
|
# that behavior
|
||||||
|
return '_bulk' in self.request.POST
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
initial = {}
|
||||||
|
mixed_values = set()
|
||||||
|
qs = self.get_queryset().annotate()
|
||||||
|
|
||||||
|
fields = (
|
||||||
|
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', 'max_usages',
|
||||||
|
'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', 'all_bundles_included',
|
||||||
|
'budget',
|
||||||
|
)
|
||||||
|
for f in fields:
|
||||||
|
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
|
||||||
|
if len(existing_values) == 1:
|
||||||
|
initial[f] = existing_values[0][f]
|
||||||
|
elif len(existing_values) > 1:
|
||||||
|
mixed_values.add(f)
|
||||||
|
if f == "max_usages":
|
||||||
|
initial[f] = 1
|
||||||
|
else:
|
||||||
|
initial[f] = None
|
||||||
|
|
||||||
|
existing_values = list(qs.order_by("item", "variation", "quota").values("item", "variation", "quota").annotate(c=Count('*')))
|
||||||
|
if len(existing_values) == 1:
|
||||||
|
i = existing_values[0]
|
||||||
|
if i["quota"]:
|
||||||
|
initial["itemvar"] = f'q-{i["quota"]}'
|
||||||
|
elif i["variation"]:
|
||||||
|
initial["itemvar"] = f'{i["item"]}-{i["variation"]}'
|
||||||
|
elif i["item"]:
|
||||||
|
initial["itemvar"] = f'{i["item"]}'
|
||||||
|
else:
|
||||||
|
initial["itemvar"] = None
|
||||||
|
elif len(existing_values) > 1:
|
||||||
|
mixed_values.add("itemvar")
|
||||||
|
initial["itemvar"] = None
|
||||||
|
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs['event'] = self.request.event
|
||||||
|
kwargs['prefix'] = 'bulkedit'
|
||||||
|
kwargs['initial'] = initial
|
||||||
|
kwargs['queryset'] = self.get_queryset()
|
||||||
|
kwargs['mixed_values'] = mixed_values
|
||||||
|
if not self.is_submitted:
|
||||||
|
kwargs['data'] = None
|
||||||
|
kwargs['files'] = None
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse('control:event.vouchers', kwargs={
|
||||||
|
'organizer': self.request.event.organizer.slug,
|
||||||
|
'event': self.request.event.slug,
|
||||||
|
})
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
# Main form
|
||||||
|
form.save()
|
||||||
|
data = {
|
||||||
|
k: v
|
||||||
|
for k, v in form.cleaned_data.items()
|
||||||
|
if k in form.changed_data
|
||||||
|
}
|
||||||
|
data['_raw_bulk_data'] = self.request.POST.dict()
|
||||||
|
for obj in self.get_queryset():
|
||||||
|
log_entries.append(
|
||||||
|
obj.log_action('pretix.voucher.changed', data=data, user=self.request.user, save=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
LogEntry.bulk_create_and_postprocess(log_entries)
|
||||||
|
|
||||||
|
messages.success(self.request, _('Your changes have been saved.'))
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['vouchers'] = self.get_queryset()
|
||||||
|
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
form = self.get_form()
|
||||||
|
is_valid = (
|
||||||
|
self.is_submitted and
|
||||||
|
form.is_valid()
|
||||||
|
)
|
||||||
|
if is_valid:
|
||||||
|
return self.form_valid(form)
|
||||||
|
else:
|
||||||
|
if self.is_submitted:
|
||||||
|
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|||||||
@@ -47,5 +47,5 @@ def escapejson(value):
|
|||||||
|
|
||||||
@keep_lazy(str, SafeText)
|
@keep_lazy(str, SafeText)
|
||||||
def escapejson_attr(value):
|
def escapejson_attr(value):
|
||||||
"""Hex encodes characters for use in a html attributw script."""
|
"""Hex encodes characters for use in a html attribute."""
|
||||||
return mark_safe(force_str(value).translate(_json_escapes_attr))
|
return mark_safe(force_str(value).translate(_json_escapes_attr))
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ class ScheduledMail(models.Model):
|
|||||||
send_to_orders = self.rule.send_to in (Rule.CUSTOMERS, Rule.BOTH)
|
send_to_orders = self.rule.send_to in (Rule.CUSTOMERS, Rule.BOTH)
|
||||||
send_to_attendees = self.rule.send_to in (Rule.ATTENDEES, Rule.BOTH)
|
send_to_attendees = self.rule.send_to in (Rule.ATTENDEES, Rule.BOTH)
|
||||||
|
|
||||||
|
position_ids = op_qs.values_list('id', flat=True)
|
||||||
|
|
||||||
for o in orders:
|
for o in orders:
|
||||||
with language(o.locale, e.settings.region):
|
with language(o.locale, e.settings.region):
|
||||||
positions = list(o.positions.all())
|
positions = list(o.positions.all())
|
||||||
@@ -191,28 +193,29 @@ class ScheduledMail(models.Model):
|
|||||||
positions = [p for p in positions if p.subevent_id == self.subevent_id]
|
positions = [p for p in positions if p.subevent_id == self.subevent_id]
|
||||||
|
|
||||||
for p in positions:
|
for p in positions:
|
||||||
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
|
if p.id in position_ids:
|
||||||
email_ctx = get_email_context(
|
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
|
||||||
event=e,
|
email_ctx = get_email_context(
|
||||||
order=o,
|
event=e,
|
||||||
invoice_address=ia,
|
order=o,
|
||||||
position=p,
|
invoice_address=ia,
|
||||||
event_or_subevent=self.subevent or e,
|
position=p,
|
||||||
)
|
event_or_subevent=self.subevent or e,
|
||||||
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
)
|
||||||
attach_ical=self.rule.attach_ical,
|
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||||
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
|
attach_ical=self.rule.attach_ical,
|
||||||
elif not o_sent and o.email:
|
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
|
||||||
email_ctx = get_email_context(
|
elif not o_sent and o.email:
|
||||||
event=e,
|
email_ctx = get_email_context(
|
||||||
order=o,
|
event=e,
|
||||||
invoice_address=ia,
|
order=o,
|
||||||
event_or_subevent=self.subevent or e,
|
invoice_address=ia,
|
||||||
)
|
event_or_subevent=self.subevent or e,
|
||||||
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
)
|
||||||
attach_ical=self.rule.attach_ical,
|
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
|
||||||
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
|
attach_ical=self.rule.attach_ical,
|
||||||
o_sent = True
|
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
|
||||||
|
o_sent = True
|
||||||
|
|
||||||
self.last_successful_order_id = o.pk
|
self.last_successful_order_id = o.pk
|
||||||
|
|
||||||
@@ -270,7 +273,7 @@ class Rule(models.Model, LoggingMixin):
|
|||||||
|
|
||||||
date_is_absolute = models.BooleanField(default=True, blank=True)
|
date_is_absolute = models.BooleanField(default=True, blank=True)
|
||||||
offset_to_event_end = models.BooleanField(default=False, blank=True) # no verbose name because not actually
|
offset_to_event_end = models.BooleanField(default=False, blank=True) # no verbose name because not actually
|
||||||
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
|
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
|
||||||
|
|
||||||
send_to = models.CharField(max_length=10, choices=SEND_TO_CHOICES, default=CUSTOMERS, verbose_name=_('Send email to'))
|
send_to = models.CharField(max_length=10, choices=SEND_TO_CHOICES, default=CUSTOMERS, verbose_name=_('Send email to'))
|
||||||
|
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ $(function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ($("#stripe_payment_intent_next_action_redirect_url").length) {
|
} else if ($("#stripe_payment_intent_next_action_redirect_url").length) {
|
||||||
let payment_intent_next_action_redirect_url = $.trim($("#stripe_payment_intent_next_action_redirect_url").html());
|
let payment_intent_next_action_redirect_url = JSON.parse($("#stripe_payment_intent_next_action_redirect_url").html());
|
||||||
pretixstripe.handlePaymentRedirectAction(payment_intent_next_action_redirect_url);
|
pretixstripe.handlePaymentRedirectAction(payment_intent_next_action_redirect_url);
|
||||||
} else if ($.trim($("#stripe_payment_intent_action_type").html()) === "promptpay_display_qr_code") {
|
} else if ($.trim($("#stripe_payment_intent_action_type").html()) === "promptpay_display_qr_code") {
|
||||||
waitingDialog.hide();
|
waitingDialog.hide();
|
||||||
@@ -432,4 +432,4 @@ $(function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<script type="text/plain" id="stripe_payment_intent_action_type">{{ payment_intent_action_type }}</script>
|
<script type="text/plain" id="stripe_payment_intent_action_type">{{ payment_intent_action_type }}</script>
|
||||||
<script type="text/plain" id="stripe_payment_intent_client_secret">{{ payment_intent_client_secret }}</script>
|
<script type="text/plain" id="stripe_payment_intent_client_secret">{{ payment_intent_client_secret }}</script>
|
||||||
{% if payment_intent_next_action_redirect_url %}
|
{% if payment_intent_next_action_redirect_url %}
|
||||||
<script type="text/plain" id="stripe_payment_intent_next_action_redirect_url">{{ payment_intent_next_action_redirect_url|safe }}</script>
|
{{ payment_intent_next_action_redirect_url|json_script:"stripe_payment_intent_next_action_redirect_url" }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if payment_intent_redirect_action_handling %}
|
{% if payment_intent_redirect_action_handling %}
|
||||||
<script type="text/plain" id="stripe_payment_intent_redirect_action_handling">{{ payment_intent_redirect_action_handling }}</script>
|
<script type="text/plain" id="stripe_payment_intent_redirect_action_handling">{{ payment_intent_redirect_action_handling }}</script>
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ const CSRF_TOKEN = document.querySelector<HTMLInputElement>('input[name=csrfmidd
|
|||||||
function handleAuthError (response: Response): void {
|
function handleAuthError (response: Response): void {
|
||||||
if ([401, 403].includes(response.status)) {
|
if ([401, 403].includes(response.status)) {
|
||||||
window.location.href = '/control/login?next=' + encodeURIComponent(
|
window.location.href = '/control/login?next=' + encodeURIComponent(
|
||||||
window.location.pathname + window.location.search + window.location.hash
|
window.location.pathname + window.location.search
|
||||||
)
|
) + window.location.hash
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ voucher_redeem_info = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``voucher``
|
Arguments: ``voucher``
|
||||||
|
|
||||||
This signal is sent out to display additional information on the "redeem a voucher" page
|
This signal is sent out to display additional information on the "redeem a voucher" page.
|
||||||
|
You are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -194,6 +195,7 @@ Arguments: ``request``
|
|||||||
|
|
||||||
This signals allows you to add HTML content to the confirmation page that is presented at the
|
This signals allows you to add HTML content to the confirmation page that is presented at the
|
||||||
end of the checkout process, just before the order is being created.
|
end of the checkout process, just before the order is being created.
|
||||||
|
You are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event. A ``request``
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event. A ``request``
|
||||||
argument will contain the request object.
|
argument will contain the request object.
|
||||||
@@ -276,7 +278,8 @@ order_info = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``request``
|
Arguments: ``order``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on the order detail page
|
This signal is sent out to display additional information on the order detail page.
|
||||||
|
Return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -285,7 +288,8 @@ position_info = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``position``, ``request``
|
Arguments: ``order``, ``position``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on the position detail page
|
This signal is sent out to display additional information on the position detail page.
|
||||||
|
Return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -294,7 +298,8 @@ order_info_top = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``request``
|
Arguments: ``order``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on top of the order detail page
|
This signal is sent out to display additional information on top of the order detail page.
|
||||||
|
Return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -303,7 +308,8 @@ position_info_top = EventPluginSignal()
|
|||||||
"""
|
"""
|
||||||
Arguments: ``order``, ``position``, ``request``
|
Arguments: ``order``, ``position``, ``request``
|
||||||
|
|
||||||
This signal is sent out to display additional information on top of the position detail page
|
This signal is sent out to display additional information on top of the position detail page.
|
||||||
|
Return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
"""
|
"""
|
||||||
@@ -349,7 +355,7 @@ This signal is sent out to display additional information on the frontpage above
|
|||||||
of products and but below a custom frontpage text.
|
of products and but below a custom frontpage text.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
||||||
receivers are expected to return HTML.
|
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
render_seating_plan = EventPluginSignal()
|
render_seating_plan = EventPluginSignal()
|
||||||
@@ -361,7 +367,7 @@ You will be passed the ``request`` as a keyword argument. If applicable, a ``sub
|
|||||||
``voucher`` argument might be given.
|
``voucher`` argument might be given.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
||||||
receivers are expected to return HTML.
|
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
front_page_bottom = EventPluginSignal()
|
front_page_bottom = EventPluginSignal()
|
||||||
@@ -372,7 +378,7 @@ This signal is sent out to display additional information on the frontpage below
|
|||||||
of products.
|
of products.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
||||||
receivers are expected to return HTML.
|
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
front_page_bottom_widget = EventPluginSignal()
|
front_page_bottom_widget = EventPluginSignal()
|
||||||
@@ -383,7 +389,7 @@ This signal is sent out to display additional information on the frontpage below
|
|||||||
of products if the front page is shown in the widget.
|
of products if the front page is shown in the widget.
|
||||||
|
|
||||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
|
||||||
receivers are expected to return HTML.
|
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
checkout_all_optional = EventPluginSignal()
|
checkout_all_optional = EventPluginSignal()
|
||||||
@@ -403,7 +409,7 @@ Arguments: ``item``, ``variation``, ``subevent``
|
|||||||
|
|
||||||
This signal is sent out when the description of an item or variation is rendered and allows you to append
|
This signal is sent out when the description of an item or variation is rendered and allows you to append
|
||||||
additional text to the description. You are passed the ``item``, ``variation`` and ``subevent``. You are
|
additional text to the description. You are passed the ``item``, ``variation`` and ``subevent``. You are
|
||||||
expected to return HTML.
|
expected to return markdown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
register_cookie_providers = EventPluginSignal()
|
register_cookie_providers = EventPluginSignal()
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function async_task_check_error(jqXHR, textStatus, errorThrown) {
|
|||||||
"use strict";
|
"use strict";
|
||||||
var respdom = $(jqXHR.responseText);
|
var respdom = $(jqXHR.responseText);
|
||||||
var c = respdom.filter('.container');
|
var c = respdom.filter('.container');
|
||||||
|
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
|
||||||
|
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
|
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
|
||||||
// This is a failed form validation, let's just use it
|
// This is a failed form validation, let's just use it
|
||||||
$("body").data('ajaxing', false);
|
$("body").data('ajaxing', false);
|
||||||
@@ -167,6 +171,10 @@ function async_task_callback(data, jqXHR, status) {
|
|||||||
function async_task_error(jqXHR, textStatus, errorThrown) {
|
function async_task_error(jqXHR, textStatus, errorThrown) {
|
||||||
"use strict";
|
"use strict";
|
||||||
$("body").data('ajaxing', false);
|
$("body").data('ajaxing', false);
|
||||||
|
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
|
||||||
|
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
waitingDialog.hide();
|
waitingDialog.hide();
|
||||||
if (textStatus === "timeout") {
|
if (textStatus === "timeout") {
|
||||||
alert(gettext("The request took too long. Please try again."));
|
alert(gettext("The request took too long. Please try again."));
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ var i18nToString = function (i18nstring) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$(document).ajaxError(function (event, jqXHR, settings, thrownError) {
|
$(document).ajaxError(function (event, jqXHR, settings, thrownError) {
|
||||||
waitingDialog.hide();
|
|
||||||
var c = $(jqXHR.responseText).filter('.container');
|
var c = $(jqXHR.responseText).filter('.container');
|
||||||
if (jqXHR.responseText && jqXHR.responseText.indexOf("<!-- pretix-login-marker -->") !== -1) {
|
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
|
||||||
location.href = '/control/login?next=' + encodeURIComponent(location.pathname + location.search + location.hash)
|
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
|
||||||
} else if (c.length > 0) {
|
} else if (c.length > 0) {
|
||||||
|
waitingDialog.hide();
|
||||||
ajaxErrDialog.show(c.first().html());
|
ajaxErrDialog.show(c.first().html());
|
||||||
} else if (thrownError !== "abort" && thrownError !== "") {
|
} else if (thrownError !== "abort" && thrownError !== "") {
|
||||||
|
waitingDialog.hide();
|
||||||
console.error(event, jqXHR, settings, thrownError);
|
console.error(event, jqXHR, settings, thrownError);
|
||||||
alert(gettext('Unknown error.'));
|
alert(gettext('Unknown error.'));
|
||||||
}
|
}
|
||||||
@@ -873,14 +874,6 @@ function setup_basics(el) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
el.find(".qrcode-canvas").each(function () {
|
|
||||||
$(this).qrcode(
|
|
||||||
{
|
|
||||||
text: $.trim($($(this).attr("data-qrdata")).html())
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
el.find(".propagated-settings-box").find("input, textarea, select").not("[readonly]")
|
el.find(".propagated-settings-box").find("input, textarea, select").not("[readonly]")
|
||||||
.attr("data-propagated-locked", "true").prop("readonly", true);
|
.attr("data-propagated-locked", "true").prop("readonly", true);
|
||||||
|
|
||||||
|
|||||||
@@ -964,6 +964,8 @@ def test_redeemed_is_not_writable(token_client, organizer, event, item):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_multiple_vouchers(token_client, organizer, event, item):
|
def test_create_multiple_vouchers(token_client, organizer, event, item):
|
||||||
|
with scopes_disabled():
|
||||||
|
event.quotas.create(name="Q", size=100).items.add(item)
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
||||||
data=[
|
data=[
|
||||||
@@ -1012,6 +1014,8 @@ def test_create_multiple_vouchers(token_client, organizer, event, item):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, item):
|
def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, item):
|
||||||
|
with scopes_disabled():
|
||||||
|
event.quotas.create(name="Q", size=100).items.add(item)
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
||||||
data=[
|
data=[
|
||||||
@@ -1055,6 +1059,8 @@ def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, it
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, item):
|
def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, item):
|
||||||
|
with scopes_disabled():
|
||||||
|
event.quotas.create(name="Q", size=100).items.add(item)
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
||||||
data=[
|
data=[
|
||||||
@@ -1098,6 +1104,8 @@ def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event,
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_multiple_vouchers_autogenerate_codes(token_client, organizer, event, item):
|
def test_create_multiple_vouchers_autogenerate_codes(token_client, organizer, event, item):
|
||||||
|
with scopes_disabled():
|
||||||
|
event.quotas.create(name="Q", size=100).items.add(item)
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
||||||
data=[
|
data=[
|
||||||
@@ -1157,6 +1165,8 @@ def seat1(item, event):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_create_multiple_vouchers_duplicate_seat(token_client, organizer, event, item, seat1, seatingplan):
|
def test_create_multiple_vouchers_duplicate_seat(token_client, organizer, event, item, seat1, seatingplan):
|
||||||
|
with scopes_disabled():
|
||||||
|
event.quotas.create(name="Q", size=100).items.add(item)
|
||||||
resp = token_client.post(
|
resp = token_client.post(
|
||||||
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
|
||||||
data=[
|
data=[
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
import json
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.core import mail as djmail
|
from django.core import mail as djmail
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
@@ -43,8 +44,8 @@ from django_scopes import scopes_disabled
|
|||||||
from tests.base import SoupTestMixin, extract_form_fields
|
from tests.base import SoupTestMixin, extract_form_fields
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota, Team,
|
Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota,
|
||||||
User, Voucher, WaitingListEntry,
|
SeatingPlan, Team, User, Voucher, WaitingListEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,49 +135,49 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
|
|||||||
def test_filter_status_valid(self):
|
def test_filter_status_valid(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
v = self.event.vouchers.create(item=self.ticket)
|
v = self.event.vouchers.create(item=self.ticket)
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code in doc.content.decode()
|
assert v.code in doc.content.decode()
|
||||||
v.redeemed = 1
|
v.redeemed = 1
|
||||||
v.save()
|
v.save()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code not in doc.content.decode()
|
assert v.code not in doc.content.decode()
|
||||||
|
|
||||||
def test_filter_status_redeemed(self):
|
def test_filter_status_redeemed(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
v = self.event.vouchers.create(item=self.ticket, redeemed=1)
|
v = self.event.vouchers.create(item=self.ticket, redeemed=1)
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code in doc.content.decode()
|
assert v.code in doc.content.decode()
|
||||||
v.redeemed = 0
|
v.redeemed = 0
|
||||||
v.save()
|
v.save()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code not in doc.content.decode()
|
assert v.code not in doc.content.decode()
|
||||||
|
|
||||||
def test_filter_status_expired(self):
|
def test_filter_status_expired(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
v = self.event.vouchers.create(item=self.ticket, valid_until=now() + datetime.timedelta(days=1))
|
v = self.event.vouchers.create(item=self.ticket, valid_until=now() + datetime.timedelta(days=1))
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code not in doc.content.decode()
|
assert v.code not in doc.content.decode()
|
||||||
v.valid_until = now() - datetime.timedelta(days=1)
|
v.valid_until = now() - datetime.timedelta(days=1)
|
||||||
v.save()
|
v.save()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug))
|
||||||
assert v.code in doc.content.decode()
|
assert v.code in doc.content.decode()
|
||||||
|
|
||||||
def test_filter_tag(self):
|
def test_filter_tag(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo', tag='bar')
|
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo', tag='bar')
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?tag=bar' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=bar' % (self.orga.slug, self.event.slug))
|
||||||
assert 'ABCDEFG' in doc.content.decode()
|
assert 'ABCDEFG' in doc.content.decode()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?tag=baz' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=baz' % (self.orga.slug, self.event.slug))
|
||||||
assert 'ABCDEFG' not in doc.content.decode()
|
assert 'ABCDEFG' not in doc.content.decode()
|
||||||
|
|
||||||
def test_search_code(self):
|
def test_search_code(self):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo')
|
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo')
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?search=ABCDEFG' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=ABCDEFG' % (self.orga.slug, self.event.slug))
|
||||||
assert 'ABCDEFG' in doc.content.decode()
|
assert 'ABCDEFG' in doc.content.decode()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?search=Foo' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=Foo' % (self.orga.slug, self.event.slug))
|
||||||
assert 'ABCDEFG' in doc.content.decode()
|
assert 'ABCDEFG' in doc.content.decode()
|
||||||
doc = self.client.get('/control/event/%s/%s/vouchers/?search=12345' % (self.orga.slug, self.event.slug))
|
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=12345' % (self.orga.slug, self.event.slug))
|
||||||
assert 'ABCDEFG' not in doc.content.decode()
|
assert 'ABCDEFG' not in doc.content.decode()
|
||||||
|
|
||||||
def test_bulk_rng(self):
|
def test_bulk_rng(self):
|
||||||
@@ -806,3 +807,426 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
|
|||||||
|
|
||||||
assert 'walk-ins' in names
|
assert 'walk-ins' in names
|
||||||
assert 'waiting-list' not in names
|
assert 'waiting-list' not in names
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherBulkEditFormTest(SoupTestMixin, TransactionTestCase):
|
||||||
|
@scopes_disabled()
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||||
|
self.orga = Organizer.objects.create(name='CCC', slug='ccc')
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
organizer=self.orga, name='30C3', slug='30c3',
|
||||||
|
date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
|
||||||
|
)
|
||||||
|
t = Team.objects.create(organizer=self.orga, all_event_permissions=True)
|
||||||
|
t.members.add(self.user)
|
||||||
|
t.limit_events.add(self.event)
|
||||||
|
self.client.login(email='dummy@dummy.dummy', password='dummy')
|
||||||
|
|
||||||
|
self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2)
|
||||||
|
self.shirt = Item.objects.create(event=self.event, name='T-Shirt', default_price=12)
|
||||||
|
self.quota_shirts.items.add(self.shirt)
|
||||||
|
self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value='Red')
|
||||||
|
self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value='Blue')
|
||||||
|
self.quota_shirts.variations.add(self.shirt_red)
|
||||||
|
self.quota_shirts.variations.add(self.shirt_blue)
|
||||||
|
|
||||||
|
self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=2)
|
||||||
|
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
|
||||||
|
default_price=23)
|
||||||
|
self.quota_tickets.items.add(self.ticket)
|
||||||
|
self.url = f'/control/event/{self.orga.slug}/{self.event.slug}/vouchers/bulk_edit'
|
||||||
|
|
||||||
|
def test_simple_edit(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(
|
||||||
|
quota=self.quota_tickets,
|
||||||
|
max_usages=10,
|
||||||
|
price_mode="set",
|
||||||
|
value=13,
|
||||||
|
)
|
||||||
|
self.event.vouchers.create(
|
||||||
|
item=self.ticket,
|
||||||
|
max_usages=10,
|
||||||
|
price_mode="set",
|
||||||
|
value=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = self.post_doc(self.url, {
|
||||||
|
'__ALL': 'on',
|
||||||
|
}, follow=True)
|
||||||
|
fields = extract_form_fields(doc)
|
||||||
|
assert fields.get('bulkedit-max_usages') == '10'
|
||||||
|
assert fields.get('bulkedit-price_mode') == 'set'
|
||||||
|
assert not fields.get('bulkedit-value')
|
||||||
|
fields.update({
|
||||||
|
'_bulk': ['bulkedit__price', 'bulkeditmin_usages', 'bulkedittag', 'bulkeditshow_hidden_items'],
|
||||||
|
'bulkedit-price_mode': 'percent',
|
||||||
|
'bulkedit-value': '15',
|
||||||
|
'bulkedit-min_usages': '3',
|
||||||
|
'bulkedit-tag': 'tagged',
|
||||||
|
'bulkedit-comment': 'This is a comment', # will be ignored, as not included in _bulk
|
||||||
|
'bulkedit-show_hidden_items': '',
|
||||||
|
})
|
||||||
|
doc = self.post_doc(self.url, fields, follow=True)
|
||||||
|
assert doc.select(".alert-success")
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.price_mode == "percent"
|
||||||
|
assert v.value == Decimal("15.00")
|
||||||
|
assert v.min_usages == 3
|
||||||
|
assert v.tag == "tagged"
|
||||||
|
assert v.comment == ""
|
||||||
|
assert v.show_hidden_items is False
|
||||||
|
|
||||||
|
def _update_all(self, data: dict, expect_error: str=None):
|
||||||
|
doc = self.post_doc(self.url, {
|
||||||
|
'__ALL': 'on',
|
||||||
|
}, follow=True)
|
||||||
|
fields = extract_form_fields(doc)
|
||||||
|
fields.update(data)
|
||||||
|
doc = self.post_doc(self.url, fields, follow=True)
|
||||||
|
error_texts = [el.text for el in doc.select(".alert-danger, .has-error")]
|
||||||
|
if expect_error:
|
||||||
|
assert doc.select(".alert-danger")
|
||||||
|
assert any(expect_error in t for t in error_texts), error_texts
|
||||||
|
else:
|
||||||
|
assert doc.select(".alert-success"), error_texts
|
||||||
|
|
||||||
|
def test_change_itemvar_to_product(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets)
|
||||||
|
self.event.vouchers.create(item=self.ticket)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.ticket.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.item == self.ticket
|
||||||
|
assert not v.variation
|
||||||
|
assert not v.quota
|
||||||
|
|
||||||
|
def test_change_itemvar_to_variation(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets)
|
||||||
|
self.event.vouchers.create(item=self.ticket)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.item == self.shirt
|
||||||
|
assert v.variation == self.shirt_red
|
||||||
|
assert not v.quota
|
||||||
|
|
||||||
|
def test_change_itemvar_to_quota(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets)
|
||||||
|
self.event.vouchers.create(item=self.ticket)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'q-{self.quota_tickets.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert not v.item
|
||||||
|
assert not v.variation
|
||||||
|
assert v.quota == self.quota_tickets
|
||||||
|
|
||||||
|
def test_change_itemvar_to_all(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets)
|
||||||
|
self.event.vouchers.create(item=self.ticket)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': '',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert not v.item
|
||||||
|
assert not v.variation
|
||||||
|
assert not v.quota
|
||||||
|
|
||||||
|
def test_change_max_usages(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets, max_usages=15, redeemed=4)
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=15, redeemed=2)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditmax_usages'],
|
||||||
|
'bulkedit-max_usages': '3',
|
||||||
|
}, expect_error="already been redeemed 4 times")
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditmax_usages'],
|
||||||
|
'bulkedit-max_usages': '4',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.max_usages == 4
|
||||||
|
|
||||||
|
def _requires_one_more_quota(self, data: dict, quota=None):
|
||||||
|
self._update_all(data, expect_error="no sufficient quota")
|
||||||
|
quota = quota or self.quota_tickets
|
||||||
|
quota.size += 1
|
||||||
|
quota.save()
|
||||||
|
self._update_all(data)
|
||||||
|
|
||||||
|
def test_quota_check_change_item(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=2, redeemed=1)
|
||||||
|
self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=3, redeemed=1)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.ticket.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.item == self.ticket
|
||||||
|
|
||||||
|
def test_quota_check_change_variation(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}',
|
||||||
|
}, quota=self.quota_shirts)
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.item == self.shirt
|
||||||
|
assert v.variation == self.shirt_red
|
||||||
|
|
||||||
|
def test_quota_check_change_item_with_variations(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.shirt.pk}',
|
||||||
|
}, quota=self.quota_shirts)
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.item == self.shirt
|
||||||
|
assert not v.variation
|
||||||
|
|
||||||
|
def test_quota_check_change_expired_to_valid(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, valid_until=now() - datetime.timedelta(days=1))
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkeditvalid_until'],
|
||||||
|
'bulkedit-valid_until_0': '',
|
||||||
|
'bulkedit-valid_until_1': '',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert not v.valid_until
|
||||||
|
|
||||||
|
def test_quota_check_change_max_usages(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, redeemed=1)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkeditmax_usages'],
|
||||||
|
'bulkedit-max_usages': '2',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.max_usages == 2
|
||||||
|
|
||||||
|
def test_quota_check_no_change(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
# Technically overbooked, but we don't have a diff in quota
|
||||||
|
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
|
||||||
|
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
|
||||||
|
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.variation == self.shirt_blue
|
||||||
|
|
||||||
|
def test_quota_check_change_subevent(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.has_subevents = True
|
||||||
|
self.event.save()
|
||||||
|
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||||
|
se2 = self.event.subevents.create(name="Bar", date_from=now())
|
||||||
|
self.quota_tickets.subevent = se1
|
||||||
|
self.quota_tickets.save()
|
||||||
|
Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
|
||||||
|
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': f'{se1.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.subevent == se1
|
||||||
|
|
||||||
|
def test_change_subevent_quota_invalid(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.has_subevents = True
|
||||||
|
self.event.save()
|
||||||
|
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||||
|
se2 = self.event.subevents.create(name="Bar", date_from=now())
|
||||||
|
self.quota_tickets.subevent = se1
|
||||||
|
self.quota_tickets.save()
|
||||||
|
v1 = self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1)
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': f'{se2.pk}',
|
||||||
|
}, expect_error="selected quota does not match the selected subevent")
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': '',
|
||||||
|
}, expect_error="has no date selected")
|
||||||
|
v1.quota = None
|
||||||
|
v1.item = self.ticket
|
||||||
|
v1.save()
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': '',
|
||||||
|
}, expect_error="If you want this voucher to block quota, you need to select a specific date")
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.subevent == se1
|
||||||
|
|
||||||
|
def test_change_missing_itemvar_with_block_quota(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True)
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True)
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': '',
|
||||||
|
}, expect_error="You need to select a specific product or quota if this voucher should reserve")
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar', 'bulkeditblock_quota'],
|
||||||
|
'bulkedit-itemvar': '',
|
||||||
|
'bulkedit-block_quota': '',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert not v.subevent
|
||||||
|
assert not v.block_quota
|
||||||
|
|
||||||
|
def test_change_subevent_and_quota(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.has_subevents = True
|
||||||
|
self.event.save()
|
||||||
|
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||||
|
se2 = self.event.subevents.create(name="Bar", date_from=now())
|
||||||
|
self.quota_tickets.subevent = se1
|
||||||
|
self.quota_tickets.save()
|
||||||
|
q2 = Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3)
|
||||||
|
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1)
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar', 'bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': f'{se2.pk}',
|
||||||
|
'bulkedit-itemvar': f'q-{q2.pk}',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.subevent == se2
|
||||||
|
assert v.quota == q2
|
||||||
|
|
||||||
|
def test_quota_check_change_block_quota(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=3)
|
||||||
|
self._requires_one_more_quota({
|
||||||
|
'_bulk': ['bulkeditblock_quota'],
|
||||||
|
'bulkedit-block_quota': 'on',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.block_quota
|
||||||
|
|
||||||
|
def test_ignore_quota(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=3)
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditblock_quota', 'bulkeditallow_ignore_quota'],
|
||||||
|
'bulkedit-block_quota': 'on',
|
||||||
|
'bulkedit-allow_ignore_quota': 'on',
|
||||||
|
})
|
||||||
|
with scopes_disabled():
|
||||||
|
for v in self.event.vouchers.all():
|
||||||
|
assert v.block_quota
|
||||||
|
assert v.allow_ignore_quota
|
||||||
|
|
||||||
|
@scopes_disabled()
|
||||||
|
def _create_seat(self, **kwargs):
|
||||||
|
plan = SeatingPlan.objects.create(
|
||||||
|
name="Plan", organizer=self.orga, layout="{}"
|
||||||
|
)
|
||||||
|
self.event.seating_plan = plan
|
||||||
|
self.event.save()
|
||||||
|
return self.event.seats.create(seat_number="A1", product=self.ticket, seat_guid="A1", **kwargs)
|
||||||
|
|
||||||
|
def test_seated_unsupported(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=1, seat=self._create_seat())
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditmax_usages'],
|
||||||
|
'bulkedit-max_usages': '2',
|
||||||
|
}, expect_error="Changing the maximum number of usages in bulk is not supported")
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditsubevent'],
|
||||||
|
'bulkedit-subevent': '',
|
||||||
|
}, expect_error="Changing the date in bulk is not supported")
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkedititemvar'],
|
||||||
|
'bulkedit-itemvar': f'q-{self.quota_tickets.pk}',
|
||||||
|
}, expect_error="Changing the product to a quota is not supported")
|
||||||
|
|
||||||
|
def test_seat_changed_to_valid_needs_to_be_available(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
seat = self._create_seat(blocked=True)
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditvalid_until'],
|
||||||
|
'bulkedit-valid_until_0': '',
|
||||||
|
'bulkedit-valid_until_1': '',
|
||||||
|
}, expect_error="not all assigned seats of the vouchers are still available")
|
||||||
|
|
||||||
|
seat.blocked = False
|
||||||
|
seat.save()
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditvalid_until'],
|
||||||
|
'bulkedit-valid_until_0': '',
|
||||||
|
'bulkedit-valid_until_1': '',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_seat_changed_to_valid_needs_to_be_available_subevents(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.has_subevents = True
|
||||||
|
self.event.save()
|
||||||
|
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||||
|
seat = self._create_seat(subevent=se1, blocked=True)
|
||||||
|
self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat, subevent=se1)
|
||||||
|
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditvalid_until'],
|
||||||
|
'bulkedit-valid_until_0': '',
|
||||||
|
'bulkedit-valid_until_1': '',
|
||||||
|
}, expect_error="not all assigned seats of the vouchers are still available")
|
||||||
|
|
||||||
|
seat.blocked = False
|
||||||
|
seat.save()
|
||||||
|
self._update_all({
|
||||||
|
'_bulk': ['bulkeditvalid_until'],
|
||||||
|
'bulkedit-valid_until_0': '',
|
||||||
|
'bulkedit-valid_until_1': '',
|
||||||
|
})
|
||||||
|
|||||||
@@ -426,6 +426,97 @@ def test_sendmail_rule_checked_in_get_mail(event, order, item):
|
|||||||
assert len(djmail.outbox) == 1, "email not sent"
|
assert len(djmail.outbox) == 1, "email not sent"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@scopes_disabled()
|
||||||
|
def test_sendmail_rule_checked_in_mixed_order(event, order, item):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
|
||||||
|
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
|
||||||
|
clist = event.checkin_lists.create(name="Default", all_products=True)
|
||||||
|
|
||||||
|
# receives no mail when checked in
|
||||||
|
djmail.outbox = []
|
||||||
|
perform_checkin(p1, clist, {})
|
||||||
|
assert clist.checkin_count == 1
|
||||||
|
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="checked_in",
|
||||||
|
subject='meow', template='meow meow meow')
|
||||||
|
sendmail_run_rules(None)
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@scopes_disabled()
|
||||||
|
def test_sendmail_rule_not_checked_in_mixed_order(event, order, item):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
|
||||||
|
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
|
||||||
|
clist = event.checkin_lists.create(name="Default", all_products=True)
|
||||||
|
|
||||||
|
# receives no mail when checked in
|
||||||
|
djmail.outbox = []
|
||||||
|
perform_checkin(p1, clist, {})
|
||||||
|
assert clist.checkin_count == 1
|
||||||
|
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
|
||||||
|
subject='meow', template='meow meow meow')
|
||||||
|
sendmail_run_rules(None)
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@scopes_disabled()
|
||||||
|
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email_not_matching_status(event, order, item, item2):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
|
||||||
|
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
|
||||||
|
clist = event.checkin_lists.create(name="Default", all_products=True)
|
||||||
|
|
||||||
|
# receives no mail when checked in
|
||||||
|
djmail.outbox = []
|
||||||
|
perform_checkin(p1, clist, {})
|
||||||
|
|
||||||
|
# we have no email and we are checked in
|
||||||
|
# we shouldn't trigger a fallback to order.email
|
||||||
|
p3 = order.all_positions.create(item=item, price=13)
|
||||||
|
perform_checkin(p3, clist, {})
|
||||||
|
assert clist.checkin_count == 2
|
||||||
|
|
||||||
|
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
|
||||||
|
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
|
||||||
|
sendmail_run_rules(None)
|
||||||
|
|
||||||
|
assert len(djmail.outbox) == 1
|
||||||
|
recipients = [m.to for m in djmail.outbox]
|
||||||
|
assert [p2.attendee_email] in recipients # for p2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@scopes_disabled()
|
||||||
|
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email(event, order, item, item2):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
order.save()
|
||||||
|
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
|
||||||
|
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
|
||||||
|
order.all_positions.create(item=item, price=13) # p3
|
||||||
|
order.all_positions.create(item=item, price=13) # p4
|
||||||
|
clist = event.checkin_lists.create(name="Default", all_products=True)
|
||||||
|
|
||||||
|
# receives no mail when checked in
|
||||||
|
djmail.outbox = []
|
||||||
|
perform_checkin(p1, clist, {})
|
||||||
|
assert clist.checkin_count == 1
|
||||||
|
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
|
||||||
|
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
|
||||||
|
sendmail_run_rules(None)
|
||||||
|
|
||||||
|
assert len(djmail.outbox) == 2
|
||||||
|
recipients = [m.to for m in djmail.outbox]
|
||||||
|
assert [order.email] in recipients # for p3 and p4
|
||||||
|
assert [p2.attendee_email] in recipients # for p2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def run_restriction_test(event, order, restrictions_pass=[], restrictions_fail=[]):
|
def run_restriction_test(event, order, restrictions_pass=[], restrictions_fail=[]):
|
||||||
|
|||||||
Reference in New Issue
Block a user