mirror of
https://github.com/pretix/pretix.git
synced 2026-09-18 17:04:41 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13ed9b7a8 | ||
|
|
0f504fa9e1 | ||
|
|
8159a039c8 | ||
|
|
ea40a670f4 | ||
|
|
96576b8235 | ||
|
|
10aec6d8dc |
@@ -1,2 +0,0 @@
|
||||
# Format pre-vue code with eslint where possible (2026-09-10)
|
||||
d85e52c83ed3639e040372fd0052e842e4899d90
|
||||
@@ -30,6 +30,8 @@ available_from datetime The first da
|
||||
(or ``null``).
|
||||
available_until datetime The last date time at which this discount can be applied
|
||||
(or ``null``).
|
||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
|
||||
subevent_mode strings Determines how the discount is handled when used in an
|
||||
event series. Can be ``"mixed"`` (no special effect),
|
||||
``"same"`` (discount is only applied for groups within
|
||||
@@ -108,6 +110,8 @@ Endpoints
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": null,
|
||||
"subevent_date_until": null,
|
||||
@@ -168,6 +172,8 @@ Endpoints
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": null,
|
||||
"subevent_date_until": null,
|
||||
@@ -214,6 +220,8 @@ Endpoints
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": null,
|
||||
"subevent_date_until": null,
|
||||
@@ -249,6 +257,8 @@ Endpoints
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": null,
|
||||
"subevent_date_until": null,
|
||||
@@ -313,6 +323,8 @@ Endpoints
|
||||
"sales_channels": ["web"],
|
||||
"available_from": null,
|
||||
"available_until": null,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": null,
|
||||
"subevent_date_until": null,
|
||||
|
||||
Generated
+3
-3
@@ -4148,9 +4148,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
|
||||
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
+5
-5
@@ -37,10 +37,10 @@ dependencies = [
|
||||
"css-inline==0.21.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==5.2.*,>=5.2.17",
|
||||
"Django[argon2]==5.2.*",
|
||||
"django-bootstrap3==26.2",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==9.1.*",
|
||||
"django-countries==9.0.*",
|
||||
"django-filter==26.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.7",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.14.*",
|
||||
"PyJWT==2.13.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.3.*",
|
||||
"pretix-plugin-build",
|
||||
@@ -84,7 +84,7 @@ dependencies = [
|
||||
"pycountry",
|
||||
"pycparser==3.0",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.11.*",
|
||||
"pypdf==6.5.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.69.*",
|
||||
"sentry-sdk==2.68.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.functional import lazy
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers import SalesChannelMigrationMixin
|
||||
@@ -38,7 +40,8 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Discount
|
||||
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
|
||||
'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until',
|
||||
'available_from', 'available_until', 'require_membership', 'require_membership_types',
|
||||
'subevent_mode', 'subevent_date_from', 'subevent_date_until',
|
||||
'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons',
|
||||
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
|
||||
'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
|
||||
@@ -50,6 +53,7 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
|
||||
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
|
||||
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
|
||||
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -1585,6 +1585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.invoice_address = ia
|
||||
ia.last_modified = now()
|
||||
|
||||
if order.customer_id:
|
||||
memberships = order.customer.memberships.filter(testmode=order.testmode).select_related('membership_type')
|
||||
else:
|
||||
memberships = []
|
||||
|
||||
# Generate position objects
|
||||
pos_map = {}
|
||||
for pos_data in positions_data:
|
||||
@@ -1655,7 +1660,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
||||
cp.addon_to, cp.is_bundled, pos._voucher_discount)
|
||||
cp.addon_to, cp.is_bundled, pos._voucher_discount, [
|
||||
m for m in memberships if m.is_valid(
|
||||
cp.subevent or self.context['event'],
|
||||
cp.valid_from,
|
||||
)
|
||||
])
|
||||
for cp in order_positions
|
||||
]
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.discounts.prefetch_related(
|
||||
'limit_sales_channels',
|
||||
'limit_sales_channels', 'require_membership_types',
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
|
||||
@@ -54,7 +54,6 @@ from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import BaseExporter, MultiSheetListExporter
|
||||
from ..invoicing.transmission import get_transmission_types
|
||||
from ..services.export import ExportError
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import (
|
||||
@@ -198,7 +197,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
_ = gettext
|
||||
if sheet == 'invoices':
|
||||
headers = [
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
@@ -231,18 +230,8 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
_('Payment providers'),
|
||||
_('Transmission type'),
|
||||
_('Transmission status'),
|
||||
_('Transmission date'),
|
||||
]
|
||||
|
||||
transmission_types = get_transmission_types()
|
||||
for tt in transmission_types:
|
||||
for c in tt.describe_info_columns():
|
||||
headers.append(str(tt.verbose_name) + ': ' + str(c))
|
||||
|
||||
yield headers
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -253,7 +242,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
base_qs = self.invoices_queryset(form_data)
|
||||
base_qs = self.invoices_queryset(form_data)\
|
||||
|
||||
qs = base_qs.select_related(
|
||||
'order', 'refers'
|
||||
@@ -291,7 +280,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
if mid:
|
||||
pmis.append(mid)
|
||||
pmi = '\n'.join(pmis)
|
||||
line = [
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
@@ -326,20 +315,8 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
]),
|
||||
i.transmission_type_instance.verbose_name,
|
||||
i.get_transmission_status_display(),
|
||||
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
|
||||
])
|
||||
]
|
||||
for tt in transmission_types:
|
||||
if tt.identifier == i.transmission_type:
|
||||
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
|
||||
for c in tt.describe_info_columns():
|
||||
line.append(described.get(c, ""))
|
||||
else:
|
||||
for c in tt.describe_info_columns():
|
||||
line.append("")
|
||||
yield line
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
|
||||
@@ -350,22 +350,16 @@ class WrappedPhonePrefixSelect(Select):
|
||||
return super().render(name, value or self.initial, *args, **kwargs)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
# self.choices is lazy evaluated, needs to be realized to be modifiable
|
||||
choices = list(self.choices)
|
||||
if value and choices[1][0] != value:
|
||||
matching_choices = len([1 for p, c in choices if p == value])
|
||||
if value and self.choices[1][0] != value:
|
||||
matching_choices = len([1 for p, c in self.choices if p == value])
|
||||
if matching_choices > 1:
|
||||
# Some countries share a phone prefix, for example +1 is used all over the Americas.
|
||||
# This causes a UX problem: If the default value or the existing data is +12125552368,
|
||||
# the widget will just show the first <option> entry with value="+1" as selected,
|
||||
# which alphabetically is America Samoa, although most numbers statistically are from
|
||||
# the US. As a workaround, we detect this case and add an additional choice value with
|
||||
# the US. As a workaround, we detect this case and add an aditional choice value with
|
||||
# just <option value="+1">+1</option> without an explicit country.
|
||||
self.choices = [
|
||||
choices[0],
|
||||
(value, value),
|
||||
*choices[1:],
|
||||
]
|
||||
self.choices.insert(1, (value, value))
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
|
||||
|
||||
@@ -107,9 +107,6 @@ class TransmissionType:
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return transmission_info
|
||||
|
||||
def describe_info_columns(self):
|
||||
return [f.label for f in self.invoice_address_form_fields.values()]
|
||||
|
||||
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
|
||||
form_data = self.transmission_info_to_form_data(transmission_info)
|
||||
data = []
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-13 18:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0310_question_valid_string_length_min"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="require_membership",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="require_membership_types",
|
||||
field=models.ManyToManyField(to="pretixbase.membershiptype"),
|
||||
),
|
||||
]
|
||||
@@ -296,7 +296,7 @@ class Customer(LoggedModel):
|
||||
return self.memberships.active(for_event).with_usages().filter(
|
||||
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
|
||||
testmode=testmode,
|
||||
)
|
||||
).select_related('membership_type')
|
||||
|
||||
def send_activation_mail(self):
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
@@ -166,7 +166,6 @@ class Device(LoggedModel):
|
||||
)
|
||||
security_profile = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Security profile'),
|
||||
default='full',
|
||||
null=True,
|
||||
blank=False
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from math import ceil, inf
|
||||
from typing import Dict
|
||||
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
@@ -35,10 +35,20 @@ from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
|
||||
PositionInfo = namedtuple('PositionInfo',
|
||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
||||
'voucher_discount'])
|
||||
if TYPE_CHECKING:
|
||||
from pretix.base.models import Membership
|
||||
|
||||
|
||||
class PositionInfo(NamedTuple):
|
||||
item_id: int
|
||||
subevent_id: Optional[int]
|
||||
subevent_date_from: Optional[datetime.datetime]
|
||||
line_price_gross: Decimal
|
||||
addon_to: bool
|
||||
voucher_discount: Decimal
|
||||
memberships_valid_by_time: Optional[List["Membership"]] = None
|
||||
|
||||
|
||||
class Discount(LoggedModel):
|
||||
@@ -96,6 +106,16 @@ class Discount(LoggedModel):
|
||||
choices=SUBEVENT_MODE_CHOICES,
|
||||
)
|
||||
|
||||
require_membership = models.BooleanField(
|
||||
verbose_name=_('Require a valid membership'),
|
||||
default=False,
|
||||
)
|
||||
require_membership_types = models.ManyToManyField(
|
||||
'MembershipType',
|
||||
verbose_name=_('Allowed membership types'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
condition_all_products = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Apply to all products (including newly created ones)")
|
||||
@@ -239,6 +259,46 @@ class Discount(LoggedModel):
|
||||
]}
|
||||
)
|
||||
|
||||
if data.get('require_membership'):
|
||||
if not data.get('require_membership_types'):
|
||||
raise ValidationError(
|
||||
{'require_membership_types': [
|
||||
_("If a valid membership is required, at least one valid membership type needs to be selected.")
|
||||
]}
|
||||
)
|
||||
for mt in data.get('require_membership_types', []):
|
||||
# These conditions are a bit annoying/surprising, we could also just "ignore" the
|
||||
# transferable/allow_parallel_usage/max_usages flags, but maybe we DO want to honor them for discounts
|
||||
# some day for use cases like "every member can get one discounted ticket per show for themselves", and
|
||||
# then we'd be unable to move forward if we historically ignored them.
|
||||
if not mt.transferable:
|
||||
raise ValidationError(
|
||||
{'require_membership_types': [
|
||||
_('The membership type "{type}" cannot be used for discounts since it is not transferable, '
|
||||
'which is currently not supported for discounts.').format(
|
||||
type=mt.name,
|
||||
)
|
||||
]}
|
||||
)
|
||||
elif not mt.allow_parallel_usage:
|
||||
raise ValidationError(
|
||||
{'require_membership_types': [
|
||||
_('The membership type "{type}" cannot be used for discounts since it does not allow parallel '
|
||||
'usage, which is currently not supported for discounts.').format(
|
||||
type=mt.name,
|
||||
)
|
||||
]}
|
||||
)
|
||||
elif mt.max_usages is not None:
|
||||
raise ValidationError(
|
||||
{'require_membership_types': [
|
||||
_('The membership type "{type}" cannot be used for discounts since it has a maximum number of '
|
||||
'usages, which is currently not supported for discounts.').format(
|
||||
type=mt.name,
|
||||
)
|
||||
]}
|
||||
)
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.orderposition_set.exists()
|
||||
|
||||
@@ -250,6 +310,7 @@ class Discount(LoggedModel):
|
||||
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
|
||||
'subevent_mode': self.subevent_mode,
|
||||
'benefit_same_products': self.benefit_same_products,
|
||||
# many-to-many is not passed here, therefore we also call validate_config in form and serializer
|
||||
})
|
||||
|
||||
def is_available_by_time(self, now_dt=None) -> bool:
|
||||
@@ -343,7 +404,7 @@ class Discount(LoggedModel):
|
||||
if collect_potential_discounts is not None:
|
||||
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
||||
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
|
||||
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
|
||||
# but only 1 t-shirt) -> 1 shirt definitively potential discount
|
||||
for idx in consume_idx:
|
||||
collect_potential_discounts[idx] = [
|
||||
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
|
||||
@@ -400,19 +461,25 @@ class Discount(LoggedModel):
|
||||
limit_products = set()
|
||||
if not self.condition_all_products:
|
||||
limit_products = {p.pk for p in self.condition_limit_products.all()}
|
||||
membership_types = set()
|
||||
if self.require_membership and self.require_membership_types:
|
||||
membership_types = {mt.pk for mt in self.require_membership_types.all()}
|
||||
|
||||
# First, filter out everything not even covered by our product scope
|
||||
condition_candidates = [
|
||||
idx
|
||||
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
|
||||
for idx, p in
|
||||
positions.items()
|
||||
if (
|
||||
(self.condition_all_products or item_id in limit_products) and
|
||||
(self.condition_apply_to_addons or not is_addon_to) and
|
||||
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
|
||||
and (not subevent_id or (
|
||||
self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
|
||||
self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
|
||||
(self.condition_all_products or p.item_id in limit_products) and
|
||||
(self.condition_apply_to_addons or not p.addon_to) and
|
||||
(not self.condition_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
|
||||
(not p.subevent_id or (
|
||||
self.subevent_date_from is None or p.subevent_date_from >= self.subevent_date_from)) and (
|
||||
self.subevent_date_until is None or p.subevent_date_from <= self.subevent_date_until) and
|
||||
(not self.require_membership or (
|
||||
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
|
||||
))
|
||||
)
|
||||
]
|
||||
|
||||
@@ -422,12 +489,15 @@ class Discount(LoggedModel):
|
||||
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
|
||||
benefit_candidates = [
|
||||
idx
|
||||
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
|
||||
for idx, p in
|
||||
positions.items()
|
||||
if (
|
||||
item_id in benefit_products and
|
||||
(self.benefit_apply_to_addons or not is_addon_to) and
|
||||
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
|
||||
p.item_id in benefit_products and
|
||||
(self.benefit_apply_to_addons or not p.addon_to) and
|
||||
(not self.benefit_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
|
||||
(not self.require_membership or (
|
||||
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
|
||||
))
|
||||
)
|
||||
]
|
||||
|
||||
@@ -528,3 +598,38 @@ class Discount(LoggedModel):
|
||||
None
|
||||
)
|
||||
return result
|
||||
|
||||
def condition_matches_product(self, item) -> bool:
|
||||
return self.condition_all_products or item in self.condition_limit_products.all()
|
||||
|
||||
def valid_for_memberships(self, memberships, event, subevent, item, requested_valid_from=None) -> bool:
|
||||
if not self.require_membership_types:
|
||||
return True
|
||||
if not memberships:
|
||||
return False
|
||||
memberships = [
|
||||
m for m in memberships
|
||||
# Filter currently unsupported configurations
|
||||
if (
|
||||
m.membership_type.max_usages is None and
|
||||
m.membership_type.transferable and
|
||||
m.membership_type.allow_parallel_usage and
|
||||
# Filter list of membership types
|
||||
m.membership_type in self.require_membership_types.all()
|
||||
)
|
||||
]
|
||||
valid_from, _ = item.compute_validity(
|
||||
requested_start=(
|
||||
max(requested_valid_from, time_machine_now())
|
||||
if requested_valid_from and item.validity_dynamic_start_choice
|
||||
else time_machine_now()
|
||||
),
|
||||
override_tz=self.event.timezone,
|
||||
)
|
||||
return any(
|
||||
m.is_valid(
|
||||
subevent or event,
|
||||
valid_from,
|
||||
valid_from_not_chosen=item.validity_dynamic_start_choice and not requested_valid_from
|
||||
) for m in memberships
|
||||
)
|
||||
|
||||
@@ -1081,6 +1081,7 @@ class Event(EventMixin, LoggedModel):
|
||||
c_items = list(d.condition_limit_products.all())
|
||||
b_items = list(d.benefit_limit_products.all())
|
||||
limit_sales_channels = list(d.limit_sales_channels.all())
|
||||
require_membership_types = list(d.require_membership_types.all())
|
||||
d.pk = None
|
||||
d.event = self
|
||||
d._prefetched_objects_cache = {}
|
||||
@@ -1092,9 +1093,10 @@ class Event(EventMixin, LoggedModel):
|
||||
for i in b_items:
|
||||
if i.pk in item_map:
|
||||
d.benefit_limit_products.add(item_map[i.pk])
|
||||
|
||||
if not d.all_sales_channels:
|
||||
d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
|
||||
if require_membership_types and not is_cross_organizer:
|
||||
d.require_membership_types.set(require_membership_types)
|
||||
|
||||
question_map = {}
|
||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||
|
||||
@@ -510,7 +510,9 @@ class Item(LoggedModel):
|
||||
verbose_name=_("Free price input"),
|
||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||
"additional donations for your event.")
|
||||
"additional donations for your event. We recommend against combining this feature with automatic "
|
||||
"discounts since discounts are applied as the last step of price computation, which means that the "
|
||||
"price entered by the customer will be modified again.")
|
||||
)
|
||||
free_price_suggestion = models.DecimalField(
|
||||
verbose_name=_("Suggested price"),
|
||||
|
||||
@@ -3413,6 +3413,15 @@ class CartPosition(AbstractPosition):
|
||||
def valid_until(self):
|
||||
return self.predicted_validity[1]
|
||||
|
||||
def memberships_valid_by_time(self, memberships):
|
||||
return [
|
||||
m for m in memberships if m.is_valid(
|
||||
self.subevent or self.event,
|
||||
self.valid_from,
|
||||
valid_from_not_chosen=self.item.validity_dynamic_start_choice and not self.requested_valid_from
|
||||
) for m in memberships
|
||||
]
|
||||
|
||||
|
||||
class InvoiceAddress(models.Model):
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
+34
-40
@@ -801,18 +801,6 @@ def generate_compressed_addon_list(op, order, event, only_checked_in=False):
|
||||
return addonlist
|
||||
|
||||
|
||||
def get_sizebox(page: pypdf.PageObject):
|
||||
mediabox = page.mediabox
|
||||
cropbox = page.cropbox
|
||||
|
||||
return pypdf.generic.RectangleObject((
|
||||
max(mediabox[0], cropbox[0]),
|
||||
max(mediabox[1], cropbox[1]),
|
||||
min(mediabox[2], cropbox[2]),
|
||||
min(mediabox[3], cropbox[3]),
|
||||
))
|
||||
|
||||
|
||||
class Renderer:
|
||||
|
||||
def __init__(self, event, layout, background_file):
|
||||
@@ -1165,10 +1153,11 @@ class Renderer:
|
||||
elif o['type'] == "poweredby":
|
||||
self._draw_poweredby(canvas, op, o)
|
||||
if self.bg_pdf:
|
||||
first_page = self.bg_pdf.pages[0]
|
||||
sizebox = get_sizebox(first_page)
|
||||
page_size = (sizebox.width, sizebox.height)
|
||||
if first_page.rotation in (90, 270):
|
||||
page_size = (
|
||||
self.bg_pdf.pages[0].mediabox[2] - self.bg_pdf.pages[0].mediabox[0],
|
||||
self.bg_pdf.pages[0].mediabox[3] - self.bg_pdf.pages[0].mediabox[1]
|
||||
)
|
||||
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
|
||||
# swap dimensions due to pdf being rotated
|
||||
page_size = page_size[::-1]
|
||||
canvas.setPageSize(page_size)
|
||||
@@ -1243,7 +1232,9 @@ class Renderer:
|
||||
|
||||
for i, page in enumerate(fg_pdf.pages):
|
||||
bg_page = self.bg_pdf.pages[i]
|
||||
_merge_with_correct_page_media_box(output, page, bg_page)
|
||||
_correct_page_media_box(bg_page)
|
||||
page.merge_page(bg_page, over=False)
|
||||
output.add_page(page)
|
||||
|
||||
# pdf_header is a string like "%pdf-X.X"
|
||||
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
||||
@@ -1308,36 +1299,39 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
|
||||
bg_pdf.write(bg_filename)
|
||||
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
|
||||
else:
|
||||
output = PdfWriter()
|
||||
for i, page in enumerate(fg_pdf.pages):
|
||||
bg_page = bg_pdf.pages[i]
|
||||
_merge_with_correct_page_media_box(output, page, bg_page)
|
||||
_correct_page_media_box(bg_page)
|
||||
page.merge_page(bg_page, over=False)
|
||||
|
||||
# pdf_header is a string like "%pdf-X.X"
|
||||
output.pdf_header = (
|
||||
bg_pdf.pdf_header
|
||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:])
|
||||
else fg_pdf.pdf_header
|
||||
)
|
||||
output.write(out_file)
|
||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
||||
fg_pdf.pdf_header = bg_pdf.pdf_header
|
||||
|
||||
fg_pdf.write(out_file)
|
||||
|
||||
|
||||
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
|
||||
"""
|
||||
Adds fg_page to output, merging bg_page behind it.
|
||||
|
||||
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
|
||||
def _correct_page_media_box(page: pypdf.PageObject):
|
||||
if page.rotation != 0:
|
||||
page.transfer_rotation_to_content()
|
||||
media_box = page.mediabox
|
||||
trsf = pypdf.Transformation()
|
||||
if bg_page.rotation != 0:
|
||||
trsf = trsf.rotate(-bg_page.rotation)
|
||||
|
||||
mb = get_sizebox(bg_page)
|
||||
pt1 = trsf.apply_on(mb.lower_left)
|
||||
pt2 = trsf.apply_on(mb.upper_right)
|
||||
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
|
||||
|
||||
fg_page = output.add_page(fg_page)
|
||||
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
|
||||
if media_box.bottom != 0:
|
||||
trsf = trsf.translate(0, -media_box.bottom)
|
||||
if media_box.left != 0:
|
||||
trsf = trsf.translate(-media_box.left, 0)
|
||||
page.add_transformation(trsf, False)
|
||||
for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]:
|
||||
if b in page:
|
||||
rr = pypdf.generic.RectangleObject(page[b])
|
||||
pt1 = trsf.apply_on(rr.lower_left)
|
||||
pt2 = trsf.apply_on(rr.upper_right)
|
||||
page[pypdf.generic.NameObject(b)] = pypdf.generic.RectangleObject((
|
||||
min(pt1[0], pt2[0]),
|
||||
min(pt1[1], pt2[1]),
|
||||
max(pt1[0], pt2[0]),
|
||||
max(pt1[1], pt2[1]),
|
||||
))
|
||||
|
||||
|
||||
@deconstructible
|
||||
|
||||
@@ -47,6 +47,7 @@ from django.db import DatabaseError, transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
|
||||
from django.db.models.aggregates import Min
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
|
||||
@@ -57,8 +58,8 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
|
||||
Seat, SeatCategoryMapping, Voucher,
|
||||
CartPosition, Customer, Event, InvoiceAddress, Item, ItemVariation,
|
||||
SalesChannel, Seat, SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import CheckoutSession, OrderFee
|
||||
@@ -417,13 +418,15 @@ class CartManager:
|
||||
}
|
||||
|
||||
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
|
||||
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None):
|
||||
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None,
|
||||
customer: Customer=None):
|
||||
"""
|
||||
Creates a new CartManager for an event.
|
||||
"""
|
||||
self.event = event
|
||||
self.cart_id = cart_id
|
||||
self.real_now_dt = now()
|
||||
self.customer = customer
|
||||
self._operations = []
|
||||
self._quota_diff = Counter()
|
||||
self._voucher_use_diff = Counter()
|
||||
@@ -1544,7 +1547,7 @@ class CartManager:
|
||||
self._sales_channel.identifier,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(self.memberships))
|
||||
for cp in positions
|
||||
]
|
||||
)
|
||||
@@ -1559,6 +1562,13 @@ class CartManager:
|
||||
|
||||
return diff
|
||||
|
||||
@cached_property
|
||||
def memberships(self):
|
||||
if self.customer:
|
||||
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
|
||||
else:
|
||||
return []
|
||||
|
||||
def _remove_parents_if_bundles_are_removed(self):
|
||||
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||
for op in self._operations:
|
||||
@@ -1712,7 +1722,8 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None,
|
||||
customer: int=None) -> None:
|
||||
"""
|
||||
Adds a list of items to a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1734,10 +1745,16 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
|
||||
sales_channel=sales_channel)
|
||||
sales_channel=sales_channel, customer=customer)
|
||||
cm.add_new_items(items)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
@@ -1747,7 +1764,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
|
||||
"""
|
||||
:param event: The event ID in question
|
||||
:param voucher: A voucher code
|
||||
@@ -1758,9 +1775,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
|
||||
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
|
||||
cm.apply_voucher(voucher)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
@@ -1770,7 +1792,8 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en',
|
||||
sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
|
||||
"""
|
||||
Removes an item specified by its position ID from a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1782,9 +1805,14 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
|
||||
cm.remove_item(position)
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
@@ -1794,7 +1822,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
|
||||
"""
|
||||
Removes all items from a user's cart.
|
||||
:param event: The event ID in question
|
||||
@@ -1805,9 +1833,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
|
||||
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
|
||||
cm.clear()
|
||||
cm.commit()
|
||||
except LockTimeoutException:
|
||||
@@ -1817,7 +1850,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
|
||||
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> dict:
|
||||
"""
|
||||
Resets the expiry time of a cart to the configured reservation time of this event.
|
||||
Limited to 11x the reservation time.
|
||||
@@ -1830,9 +1863,14 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
|
||||
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
|
||||
cm.commit()
|
||||
return {
|
||||
"success": cm.num_extended_positions,
|
||||
@@ -1848,7 +1886,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
|
||||
|
||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
|
||||
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
|
||||
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
|
||||
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
|
||||
"""
|
||||
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
|
||||
ensure the requested addon state.
|
||||
@@ -1869,9 +1907,14 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
|
||||
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||
except SalesChannel.DoesNotExist:
|
||||
raise CartError("Invalid sales channel.")
|
||||
if customer:
|
||||
try:
|
||||
customer = event.organizer.customers.get(pk=customer)
|
||||
except Customer.DoesNotExist:
|
||||
raise CartError("Invalid customer.")
|
||||
try:
|
||||
try:
|
||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
|
||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel, customer=customer)
|
||||
cm.set_addons(addons)
|
||||
cm.add_new_items(add_to_cart_items)
|
||||
cm.commit()
|
||||
|
||||
@@ -51,6 +51,7 @@ class CrossSellingService:
|
||||
self.sales_channel = sales_channel
|
||||
self.cartpositions = cartpositions
|
||||
self.customer = customer
|
||||
self._discount_cache = {}
|
||||
|
||||
def get_data(self):
|
||||
if self.event.has_subevents:
|
||||
@@ -111,6 +112,13 @@ class CrossSellingService:
|
||||
}
|
||||
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
|
||||
|
||||
@cached_property
|
||||
def memberships(self):
|
||||
if self.customer:
|
||||
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
|
||||
else:
|
||||
return []
|
||||
|
||||
@cached_property
|
||||
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
|
||||
potential_discounts_by_cartpos = defaultdict(list)
|
||||
@@ -122,7 +130,8 @@ class CrossSellingService:
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||
cp.addon_to, cp.is_bundled,
|
||||
cp.listed_price - cp.price_after_voucher)
|
||||
cp.listed_price - cp.price_after_voucher,
|
||||
cp.memberships_valid_by_time(self.memberships))
|
||||
for cp in self.cartpositions
|
||||
],
|
||||
collect_potential_discounts=potential_discounts_by_cartpos
|
||||
@@ -177,6 +186,7 @@ class CrossSellingService:
|
||||
)
|
||||
if self.customer else None
|
||||
),
|
||||
_discount_cache=self._discount_cache,
|
||||
)
|
||||
new_items = list()
|
||||
for item in items:
|
||||
|
||||
@@ -86,7 +86,7 @@ def create_membership(customer: Customer, position: OrderPosition):
|
||||
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
|
||||
valid_from_not_chosen=False):
|
||||
"""
|
||||
Validate that a set of cart or order positions. This currently does not validate
|
||||
Validate that a set of cart or order positions.
|
||||
|
||||
:param customer: Customer to validate for
|
||||
:param positions: List of order or cart positions
|
||||
|
||||
@@ -56,7 +56,7 @@ from django.db.models import (
|
||||
from django.db.models.functions import Cast, Greatest
|
||||
from django.db.transaction import get_connection
|
||||
from django.dispatch import receiver
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
||||
from django_scopes import scopes_disabled
|
||||
@@ -923,12 +923,18 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
continue
|
||||
|
||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
|
||||
|
||||
if customer:
|
||||
memberships = lazy(lambda: list(customer.memberships.filter(testmode=event.testmode).select_related('membership_type')), list)()
|
||||
else:
|
||||
memberships = []
|
||||
|
||||
discount_results = apply_discounts(
|
||||
event,
|
||||
sales_channel.identifier,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(memberships))
|
||||
for cp in sorted_positions
|
||||
]
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ from django.db.models import Q
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
||||
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||
ItemVariation, Membership, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||
)
|
||||
from pretix.base.models.discount import Discount, PositionInfo
|
||||
from pretix.base.models.event import Event, SubEvent
|
||||
@@ -167,14 +167,15 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
||||
|
||||
|
||||
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
|
||||
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal, List[Membership]]],
|
||||
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||
"""
|
||||
Applies any dynamic discounts to a cart
|
||||
|
||||
:param event: Event the cart belongs to
|
||||
:param sales_channel: Sales channel the cart was created with
|
||||
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
|
||||
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled,
|
||||
voucher_discount, memberships_valid_by_time)``
|
||||
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
|
||||
a better result may be given if addons to the same main product have the same distinct value.
|
||||
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
||||
@@ -190,6 +191,17 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
sales_channel = sales_channel.identifier
|
||||
new_prices = {}
|
||||
|
||||
def _filter_memberships(memberships: List[Membership]) -> List[Membership]:
|
||||
return [
|
||||
# Currently unsupported configurations
|
||||
m for m in memberships
|
||||
if (
|
||||
m.membership_type.max_usages is None and
|
||||
m.membership_type.transferable and
|
||||
m.membership_type.allow_parallel_usage
|
||||
)
|
||||
]
|
||||
|
||||
discount_qs = event.discounts.filter(
|
||||
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
|
||||
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
|
||||
@@ -198,9 +210,11 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||
for discount in discount_qs:
|
||||
result = discount.apply({
|
||||
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
|
||||
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount,
|
||||
_filter_memberships(memberships_valid_by_time))
|
||||
for
|
||||
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
|
||||
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount,
|
||||
memberships_valid_by_time)
|
||||
in enumerate(positions)
|
||||
if not is_bundled and idx not in new_prices
|
||||
}, collect_potential_discounts)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,7 +20,6 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Optional
|
||||
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from babel.numbers import format_currency
|
||||
@@ -36,32 +35,32 @@ register = template.Library()
|
||||
|
||||
|
||||
@register.filter("money")
|
||||
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
|
||||
if isinstance(value, (float, int, str)):
|
||||
if value == '':
|
||||
return value
|
||||
def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
if isinstance(value, (float, int)):
|
||||
value = Decimal(value)
|
||||
if value is None:
|
||||
value = Decimal('0.00')
|
||||
if not isinstance(value, Decimal):
|
||||
if value == '':
|
||||
return value
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
if not arg:
|
||||
raise ValueError("No currency passed.")
|
||||
arg = arg.upper()
|
||||
|
||||
if value.normalize().as_tuple().exponent < -9:
|
||||
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
|
||||
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
|
||||
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
|
||||
# land to this filter (even though it should not happen).
|
||||
value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
|
||||
|
||||
currency_places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
required_places = -value.normalize().as_tuple().exponent
|
||||
render_places = max(currency_places, required_places)
|
||||
places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
if places < 2 and rounded != value:
|
||||
# We display decimal places even if we shouldn't for this currency if rounding
|
||||
# would make the numbers incorrect. If this branch executes, it's likely a bug in
|
||||
# pretix, but we won't show wrong numbers!
|
||||
if hide_currency:
|
||||
return floatformat(value, "2g")
|
||||
else:
|
||||
return '{} {}'.format(arg, floatformat(value, "2g"))
|
||||
|
||||
if hide_currency:
|
||||
return floatformat(value, f"{render_places}g")
|
||||
return floatformat(value, f"{places}g")
|
||||
|
||||
try:
|
||||
locale = Locale(get_babel_locale())
|
||||
@@ -69,29 +68,14 @@ def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_curr
|
||||
locale = "en"
|
||||
|
||||
try:
|
||||
return format_currency(
|
||||
value,
|
||||
arg,
|
||||
locale=locale,
|
||||
# We only allow Babel to restrict the digits to the digits defined by the currency if this does not remove any
|
||||
# precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still
|
||||
# better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic
|
||||
# on SQLite, since SQLite does not have fixed-decimal computation.
|
||||
currency_digits=currency_places >= required_places,
|
||||
decimal_quantization=currency_places >= required_places,
|
||||
)
|
||||
return format_currency(value, arg, locale=locale)
|
||||
except:
|
||||
return '{} {}'.format(arg, floatformat(value, f"{render_places}g"))
|
||||
|
||||
|
||||
@register.filter("money_without_currency")
|
||||
def money_filter_without_currency(value: Optional[Decimal | float | int | str], arg=''):
|
||||
return money_filter(value, arg, hide_currency=True)
|
||||
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
|
||||
|
||||
|
||||
@register.filter("money_numberfield")
|
||||
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
|
||||
if isinstance(value, (float, int, str)):
|
||||
def money_numberfield_filter(value: Decimal, arg=''):
|
||||
if isinstance(value, (float, int)):
|
||||
value = Decimal(value)
|
||||
if not isinstance(value, Decimal):
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
@@ -103,28 +87,15 @@ def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg='
|
||||
|
||||
|
||||
@register.filter(is_safe=True)
|
||||
def tax_rate_format(number: Optional[Decimal | float | int | str]):
|
||||
def tax_rate_format(number):
|
||||
"""
|
||||
Display a Decimal to its significant decimal places, used for tax rates.
|
||||
"""
|
||||
if isinstance(number, (float, int, str)):
|
||||
if number == '':
|
||||
return number
|
||||
number = Decimal(number)
|
||||
if number is None:
|
||||
number = Decimal('0.00')
|
||||
if not isinstance(number, Decimal):
|
||||
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
|
||||
if number.normalize().as_tuple().exponent < -9:
|
||||
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
|
||||
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
|
||||
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
|
||||
# land to this filter (even though it should not happen).
|
||||
number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
|
||||
assert isinstance(number, Decimal)
|
||||
return mark_safe(
|
||||
formats.number_format(
|
||||
number,
|
||||
-number.normalize().as_tuple().exponent,
|
||||
number.normalize(),
|
||||
-number.as_tuple().exponent,
|
||||
use_l10n=True,
|
||||
force_grouping=False,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,8 @@ class DiscountForm(I18nModelForm):
|
||||
'limit_sales_channels',
|
||||
'available_from',
|
||||
'available_until',
|
||||
'require_membership',
|
||||
'require_membership_types',
|
||||
'subevent_date_from',
|
||||
'subevent_date_until',
|
||||
'subevent_mode',
|
||||
@@ -69,6 +71,7 @@ class DiscountForm(I18nModelForm):
|
||||
'condition_limit_products': ItemMultipleChoiceField,
|
||||
'benefit_limit_products': ItemMultipleChoiceField,
|
||||
'limit_sales_channels': SafeModelMultipleChoiceField,
|
||||
'require_membership_types': SafeModelMultipleChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'subevent_mode': forms.RadioSelect,
|
||||
@@ -88,6 +91,9 @@ class DiscountForm(I18nModelForm):
|
||||
'data-display-dependency': '#id_condition_min_count',
|
||||
}
|
||||
),
|
||||
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -107,6 +113,13 @@ class DiscountForm(I18nModelForm):
|
||||
self.fields['condition_min_value'].required = False
|
||||
self.fields['condition_min_value'].widget.is_required = False
|
||||
|
||||
qs = self.event.organizer.membership_types.all()
|
||||
if qs:
|
||||
self.fields['require_membership_types'].queryset = qs
|
||||
else:
|
||||
del self.fields['require_membership']
|
||||
del self.fields['require_membership_types']
|
||||
|
||||
if not self.event.has_subevents:
|
||||
del self.fields['subevent_mode']
|
||||
|
||||
@@ -123,4 +136,6 @@ class DiscountForm(I18nModelForm):
|
||||
d['condition_min_count'] = 0
|
||||
if d.get('condition_min_value') is None:
|
||||
d['condition_min_value'] = Decimal('0.00')
|
||||
|
||||
Discount.validate_config(d)
|
||||
return d
|
||||
|
||||
@@ -105,12 +105,12 @@ class GlobalSettingsForm(SettingsForm):
|
||||
domain=settings.SITE_URL
|
||||
)
|
||||
)),
|
||||
('widget_vue2_origins', forms.CharField(
|
||||
('widget_vite_origins', forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': '3'}),
|
||||
required=False,
|
||||
# Not translated on purpose, this is a temporary feature and contains too many special case words
|
||||
label="Vue2 widget origins",
|
||||
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the old vue2-based widget.",
|
||||
label="Vite widget origins",
|
||||
help_text="One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget.",
|
||||
))
|
||||
])
|
||||
responses = register_global_settings.send(self)
|
||||
|
||||
@@ -774,7 +774,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
|
||||
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
|
||||
'pretix.user.settings.2fa.resetdrift': _('TOTP drift has been reset.'),
|
||||
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
|
||||
'your account.'),
|
||||
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
{% bootstrap_field form.available_until layout="control" %}
|
||||
{% bootstrap_field form.all_sales_channels layout="control" %}
|
||||
{% bootstrap_field form.limit_sales_channels layout="control" %}
|
||||
{% if form.require_membership %}
|
||||
{% bootstrap_field form.require_membership layout="control" %}
|
||||
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
|
||||
{% bootstrap_field form.require_membership_types layout="control" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Condition" context "discount" %}</legend>
|
||||
|
||||
@@ -9,7 +9,23 @@
|
||||
{% endif %}
|
||||
<form class="form-horizontal" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Usage restrictions" %}</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
The following restrictions apply whenever the membership is used to buy a product or variation that
|
||||
is only available to membership holders.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field form.transferable layout="control" %}
|
||||
{% bootstrap_field form.allow_parallel_usage layout="control" %}
|
||||
{% bootstrap_field form.max_usages layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load icon %}
|
||||
{% block title %}{% trans "User" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "User" %} {{ user.email }}</h1>
|
||||
@@ -17,10 +16,6 @@
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Generate 2FA emergency token" %}</button>
|
||||
</form>
|
||||
<form action="{% url "control:users.resetdrift" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Reset 2FA drift" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
@@ -64,72 +59,8 @@
|
||||
{% bootstrap_field form.is_verified layout='control' %}
|
||||
{% endif %}
|
||||
{% bootstrap_field form.last_login layout='control' %}
|
||||
{% bootstrap_field form.needs_password_change layout='control' %}
|
||||
{% bootstrap_field form.require_2fa layout='control' %}
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Available two-factor authentication methods" %}
|
||||
</h3>
|
||||
</div>
|
||||
<table class="panel-body table table-hover">
|
||||
{% for d in devices %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if d.devicetype == 'totp' %}
|
||||
TOTP
|
||||
{% elif d.devicetype == 'u2f' %}
|
||||
U2F
|
||||
{% elif d.devicetype == 'webauthn' %}
|
||||
WebAuthn
|
||||
{% elif d.devicetype == 'emergency' %}
|
||||
{% trans "Emergency tokens" %}
|
||||
{% endif %}
|
||||
{% if d.confirmed %}
|
||||
{% icon "check" %}
|
||||
{% else %}
|
||||
{% icon "warning" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ d.name }}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.throttling_failure_timestamp %}
|
||||
{% blocktrans trimmed with date=d.throttling_failure_timestamp|date:"SHORT_DATETIME_FORMAT" count cnt=d.throttling_failure_count %}
|
||||
1 failed attempt since {{ date }}
|
||||
{% plural %}
|
||||
{{ cnt }} failed attempts since {{ date }}
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{% endif %}
|
||||
{% if d.devicetype == 'totp' %}
|
||||
<small>
|
||||
<code>step = {{ d.step }},
|
||||
t0 = {{ d.t0 }},
|
||||
digits = {{ d.digits }},
|
||||
tolerance = {{ d.tolerance }},
|
||||
drift = {{ d.drift }},
|
||||
last_t = {{ d.last_t }}</code>
|
||||
</small>
|
||||
{% elif d.devicetype == 'u2f' %}
|
||||
<small>
|
||||
<code>sign_count = {{ d.sign_count }}</code>
|
||||
</small>
|
||||
{% elif d.devicetype == 'emergency' %}
|
||||
<small>
|
||||
<code>token_count = {{ d.token_set.count }}</code>
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field form.needs_password_change layout='control' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Team memberships" %}</legend>
|
||||
|
||||
@@ -78,7 +78,6 @@ urlpatterns = [
|
||||
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
|
||||
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
|
||||
re_path(r'^users/(?P<id>\d+)/resetdrift$', users.Reset2FADriftView.as_view(), name='users.resetdrift'),
|
||||
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
|
||||
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
|
||||
@@ -163,9 +163,6 @@ class DiscountCreate(EventPermissionRequiredMixin, CreateView):
|
||||
i = modelcopy(self.copy_from)
|
||||
i.pk = None
|
||||
kwargs['instance'] = i
|
||||
kwargs["initial"]["limit_sales_channels"] = self.copy_from.limit_sales_channels.all()
|
||||
kwargs["initial"]["condition_limit_products"] = self.copy_from.condition_limit_products.all()
|
||||
kwargs["initial"]["benefit_limit_products"] = self.copy_from.benefit_limit_products.all()
|
||||
else:
|
||||
kwargs['instance'] = Discount(event=self.request.event)
|
||||
|
||||
|
||||
@@ -41,18 +41,15 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView, TemplateView
|
||||
from django_otp.plugins.otp_static.models import StaticDevice
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from hijack import signals
|
||||
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.models import U2FDevice, User, WebAuthnDevice
|
||||
from pretix.base.models import User
|
||||
from pretix.control.forms.filter import UserFilterForm
|
||||
from pretix.control.forms.users import UserEditForm
|
||||
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
||||
from pretix.control.views import CreateView, UpdateView
|
||||
from pretix.control.views.user import (
|
||||
REAL_DEVICE_TYPES, RecentAuthenticationRequiredMixin,
|
||||
)
|
||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||
|
||||
|
||||
def get_used_backend(request):
|
||||
@@ -110,21 +107,6 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq
|
||||
ctx['backend'] = (
|
||||
b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend
|
||||
)
|
||||
|
||||
ctx['devices'] = []
|
||||
for dt in [*REAL_DEVICE_TYPES, StaticDevice]:
|
||||
objs = list(dt.objects.filter(user=self.request.user, confirmed=True))
|
||||
for obj in objs:
|
||||
if dt == TOTPDevice:
|
||||
obj.devicetype = 'totp'
|
||||
elif dt == U2FDevice:
|
||||
obj.devicetype = 'u2f'
|
||||
elif dt == WebAuthnDevice:
|
||||
obj.devicetype = 'webauthn'
|
||||
elif dt == StaticDevice:
|
||||
obj.devicetype = 'emergency'
|
||||
ctx['devices'] += objs
|
||||
|
||||
return ctx
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -201,24 +183,6 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class Reset2FADriftView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
self.object.totpdevice_set.update(drift=0)
|
||||
self.object.log_action('pretix.user.settings.2fa.resetdrift', user=self.request.user)
|
||||
messages.success(request, _(
|
||||
'The drift values for TOTP devices have been reset.'
|
||||
))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = "pretixcontrol/users/anonymize.html"
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
|
||||
headers = [
|
||||
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
|
||||
_('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages'), _('Seat'),
|
||||
_('Comment'), _('Budget'), _('Budget used')
|
||||
_('Comment')
|
||||
]
|
||||
writer.writerow(headers)
|
||||
|
||||
@@ -170,9 +170,7 @@ class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMix
|
||||
str(v.redeemed),
|
||||
str(v.max_usages),
|
||||
str(v.seat) if v.seat else "",
|
||||
str(v.comment) if v.comment else "",
|
||||
str(v.budget) if v.budget is not None else "",
|
||||
str(v.budget_used) if v.budget is not None else "",
|
||||
str(v.comment) if v.comment else ""
|
||||
]
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-09-14 23:00+0000\n"
|
||||
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"de/>\n"
|
||||
@@ -14,7 +14,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
@@ -11708,7 +11708,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Hallo,\n"
|
||||
"\n"
|
||||
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihrer Bestellung "
|
||||
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihrer Bestellung "
|
||||
"für\n"
|
||||
"{event} angefordert haben.\n"
|
||||
"\n"
|
||||
@@ -11738,8 +11738,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Hallo,\n"
|
||||
"\n"
|
||||
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihren "
|
||||
"Bestellungen für\n"
|
||||
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihren Bestellungen "
|
||||
"für\n"
|
||||
"{event} angefordert haben. Sie finden Ihre Bestellungen unter folgenden "
|
||||
"Links:\n"
|
||||
"\n"
|
||||
|
||||
@@ -8,16 +8,16 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
|
||||
"PO-Revision-Date: 2026-08-24 14:30+0000\n"
|
||||
"Last-Translator: Albizuri <oier@puntu.eus>\n"
|
||||
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"eu/>\n"
|
||||
"Language-Team: Basque <https://translate.pretix.eu/projects/pretix/pretix/eu/"
|
||||
">\n"
|
||||
"Language: eu\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -891,7 +891,7 @@ msgstr "Produktuaren data"
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Order details"
|
||||
msgstr "Eskaeraren xehetasunak"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/datasync/sourcefields.py pretix/base/modelimport_orders.py
|
||||
#: pretix/control/forms/filter.py
|
||||
@@ -17296,7 +17296,7 @@ msgstr ""
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "The order details have been changed."
|
||||
msgstr "Eskaeraren xehetasunak aldatu dira."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/logdisplay.py
|
||||
msgid "The order has been marked as unpaid."
|
||||
@@ -23106,7 +23106,7 @@ msgstr ""
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
#, python-format
|
||||
msgid "Order details: %(code)s"
|
||||
msgstr "Eskaeraren xehetasunak: %(code)s"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
#: pretix/control/templates/pretixcontrol/orders/index.html
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
|
||||
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
|
||||
"PO-Revision-Date: 2026-08-16 22:00+0000\n"
|
||||
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
|
||||
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"ja/>\n"
|
||||
"Language: ja\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -93,7 +93,7 @@ msgstr "ヘブライ語"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Hungarian"
|
||||
msgstr "ハンガリー語"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Indonesian"
|
||||
@@ -220,7 +220,7 @@ msgstr "ターゲットURL"
|
||||
#: pretix/api/models.py pretix/base/models/devices.py
|
||||
#: pretix/base/models/organizer.py
|
||||
msgid "All events (including newly created ones)"
|
||||
msgstr "すべてのイベント(今後作成されるものを含む)"
|
||||
msgstr "すべてのイベント(最近作成されたイベントを含む)"
|
||||
|
||||
#: pretix/api/models.py pretix/base/models/devices.py
|
||||
#: pretix/base/models/organizer.py
|
||||
@@ -428,8 +428,10 @@ msgid "You cannot exchange a medium for a medium."
|
||||
msgstr "メディアを別のメディアに変更することはできません。"
|
||||
|
||||
#: pretix/api/views/checkin.py
|
||||
#, fuzzy
|
||||
#| msgid "Product does not support medium exchange."
|
||||
msgid "You cannot simulate a medium exchange."
|
||||
msgstr "メディアの交換をシミュレートすることはできません。"
|
||||
msgstr "本製品は中程度の交換に対応していません。"
|
||||
|
||||
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
|
||||
#, python-brace-format
|
||||
@@ -5689,7 +5691,7 @@ msgstr "国コード(ISO 3166-1 alpha-2)"
|
||||
|
||||
#: pretix/base/models/items.py
|
||||
msgid "Asked on"
|
||||
msgstr "質問日"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/models/items.py pretix/base/models/organizer.py
|
||||
msgid ""
|
||||
@@ -7338,9 +7340,12 @@ msgid "The payment for this invoice has already been received."
|
||||
msgstr "この請求書の支払いはすでに受領済みです。"
|
||||
|
||||
#: pretix/base/payment.py
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "This payment is already being processed and can not be canceled any more."
|
||||
msgid ""
|
||||
"This payment is already being processed and cannot be canceled any more."
|
||||
msgstr "この支払いはすでに処理中のため、キャンセルできません。"
|
||||
msgstr "この支払いは処理中のため、キャンセルできません。"
|
||||
|
||||
#: pretix/base/payment.py
|
||||
msgid "Automatic refunds are not supported by this payment provider."
|
||||
@@ -8100,12 +8105,16 @@ msgid "Presale end"
|
||||
msgstr "前売り終了"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, fuzzy
|
||||
#| msgid "Order email"
|
||||
msgid "Order creation"
|
||||
msgstr "注文の作成"
|
||||
msgstr "注文者メール"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, fuzzy
|
||||
#| msgid "Order expired"
|
||||
msgid "Order expiry"
|
||||
msgstr "注文の失効"
|
||||
msgstr "注文の有効期限が切れました"
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
msgid "before"
|
||||
@@ -8134,22 +8143,22 @@ msgstr "未設定"
|
||||
#: pretix/base/reldate.py
|
||||
#, python-brace-format
|
||||
msgid "A relative date cannot be expressed as \"before\" for \"{}\""
|
||||
msgstr "相対的な日付は、「{}」に対して「before」で表すことはできません"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, python-brace-format
|
||||
msgid "A relative date cannot be expressed as \"after\" for \"{}\""
|
||||
msgstr "相対的な日付は「{}」に対して「after」として表すことはできません"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, python-brace-format
|
||||
msgid "A relative time cannot be expressed as \"before\" for \"{}\""
|
||||
msgstr "相対時間は「{}」に対して「before」で表すことはできません"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/reldate.py
|
||||
#, python-brace-format
|
||||
msgid "A relative time cannot be expressed as \"after\" for \"{}\""
|
||||
msgstr "相対時間は「{}」に対して「after」で表すことはできません"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/secrets.py
|
||||
msgid "Random (default, works with all pretix apps)"
|
||||
@@ -10058,12 +10067,12 @@ msgstr ""
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid "No dates match your criteria."
|
||||
msgstr "条件に合致する日付はありません。"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgctxt "subevents"
|
||||
msgid "Text for empty date results"
|
||||
msgstr "空の日付結果のテキスト"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgctxt "subevents"
|
||||
@@ -10074,10 +10083,6 @@ msgid ""
|
||||
"touch with you to arrange further dates. We do not recommend more than one "
|
||||
"or two sentences."
|
||||
msgstr ""
|
||||
"このテキストは、カレンダーまたは日付リストが空の場合に表示されます。たとえば"
|
||||
"、月に日付が含まれていない場合や、ユーザーが選択したフィルターに結果が見つか"
|
||||
"らない場合です。これをご利用いただくことで、今後の日程調整のために連絡する方"
|
||||
"法を宣伝できます。1文または2文以上は推奨いたしません。"
|
||||
|
||||
#: pretix/base/settings.py
|
||||
msgid "Guidance text"
|
||||
@@ -13897,20 +13902,26 @@ msgid ""
|
||||
msgstr "ギフトカードの有効期限を発行年を含めた{}年に設定しました。"
|
||||
|
||||
#: pretix/control/forms/event.py
|
||||
#, fuzzy
|
||||
#| msgid "Payment date"
|
||||
msgid "Payment term"
|
||||
msgstr "支払条件"
|
||||
msgstr "支払い日"
|
||||
|
||||
#: pretix/control/forms/event.py
|
||||
msgid "same as above"
|
||||
msgstr "上記と同じ"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/forms/event.py
|
||||
#, fuzzy
|
||||
#| msgid "Payment term in days"
|
||||
msgid "different payment term in days"
|
||||
msgstr "日数で表す異なる支払条件"
|
||||
msgstr "支払い条件(日数)"
|
||||
|
||||
#: pretix/control/forms/event.py
|
||||
#, fuzzy
|
||||
#| msgid "Payment term in minutes"
|
||||
msgid "different payment term in minutes"
|
||||
msgstr "分で表す異なる支払条件"
|
||||
msgstr "支払い期限(分単位)"
|
||||
|
||||
#: pretix/control/forms/event.py
|
||||
msgid "Prices including tax"
|
||||
@@ -22522,24 +22533,28 @@ msgstr ""
|
||||
"食事を提供する場合、ユーザーに食事制限について尋ねることができる一例です。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
#, fuzzy
|
||||
#| msgid "Create a new question"
|
||||
msgid "Create a new per-ticket question"
|
||||
msgstr "チケットごとに新しい質問を作成"
|
||||
msgstr "新しい質問を作成"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
#, fuzzy
|
||||
#| msgid "Create a new question"
|
||||
msgid "Create a new order-level question"
|
||||
msgstr "注文ごとに新しい質問を作成"
|
||||
msgstr "新しい質問を作成"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
#, fuzzy
|
||||
#| msgid "Delete question"
|
||||
msgid "Per-ticket questions"
|
||||
msgstr "チケットごとの質問"
|
||||
msgstr "質問を削除"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
msgid ""
|
||||
"These questions are asked for every ticket, so possibly multiple times in "
|
||||
"the same order."
|
||||
msgstr ""
|
||||
"これらの質問はすべてのチケットに対して尋ねられるため、同じ順序で複数回になる"
|
||||
"可能性があります。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
msgid "Create a new question"
|
||||
@@ -22558,24 +22573,28 @@ msgid "All personalized products"
|
||||
msgstr "すべてのパーソナライズされた製品"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
#, fuzzy
|
||||
#| msgid "Include questions"
|
||||
msgid "Per-order questions"
|
||||
msgstr "注文ごとの質問"
|
||||
msgstr "質問を含む"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
msgid ""
|
||||
"This functionality is in active development and expected to change "
|
||||
"significantly over the coming months."
|
||||
msgstr "この機能は現在開発が活発で、今後数か月で大幅に変化すると予想されています。"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
msgid ""
|
||||
"Per-order questions are currently not supported and will not be displayed in "
|
||||
"pretixPOS."
|
||||
msgstr "注文ごとの質問は現在サポートされておらず、pretixPOSでは表示されません。"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/questions.html
|
||||
#, fuzzy
|
||||
#| msgid "The question has been reordered."
|
||||
msgid "These questions are asked once per order."
|
||||
msgstr "これらの質問は、注文ごとに1回ずつ尋ねられます。"
|
||||
msgstr "質問の並び順が変更されました。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/items/quota.html
|
||||
#: pretix/control/templates/pretixcontrol/items/quota_edit.html
|
||||
@@ -23167,8 +23186,10 @@ msgstr "(任意)"
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
#, fuzzy
|
||||
#| msgid "Additional information"
|
||||
msgid "Additional order information"
|
||||
msgstr "追加の注文情報"
|
||||
msgstr "追加情報"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/delete.html
|
||||
msgid "Delete order"
|
||||
@@ -24835,13 +24856,14 @@ msgid "Hardware model"
|
||||
msgstr "ハードウェアの機種"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/devices.html
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Begin: %(time)s"
|
||||
msgid "Last seen: %(time)s"
|
||||
msgstr "最後の閲覧: %(time)s"
|
||||
msgstr "開始: %(time)s"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/devices.html
|
||||
msgid "No recent contact"
|
||||
msgstr "最近の連絡なし"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/devices.html
|
||||
msgid "Not yet initialized"
|
||||
@@ -28198,14 +28220,19 @@ msgstr ""
|
||||
"に高いことを意味します。ドメインのDNS設定を更新すべきです。"
|
||||
|
||||
#: pretix/control/views/mailsetup.py
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "We did not find DMARC record for your domain. This means that there is a "
|
||||
#| "very high chance most of the emails will be rejected or marked as spam. "
|
||||
#| "You should update the DNS settings of your domain."
|
||||
msgid ""
|
||||
"We did not find a DMARC record for your domain. This means that there is a "
|
||||
"very high chance most of the emails will be rejected or marked as spam. You "
|
||||
"should update the DNS settings of your domain."
|
||||
msgstr ""
|
||||
"あなたのドメインのDMARCレコードが見つかりませんでした。これは、ほとんどの"
|
||||
"メールが拒否されたかスパムとしてマークされた可能性が非常に高いことを意味しま"
|
||||
"す。ドメインのDNS設定を更新すべきです。"
|
||||
"お客様のドメインのDMARCレコードが見つかりませんでした。これは、ほとんどのメー"
|
||||
"ルが拒否されたりスパムとしてマークされたりする可能性が非常に高いことを意味し"
|
||||
"ます。ドメインのDNS設定を更新すべきです。"
|
||||
|
||||
#: pretix/control/views/mailsetup.py
|
||||
msgid "The verification code was incorrect, please try again."
|
||||
@@ -30879,7 +30906,7 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/payment.py
|
||||
msgid "Allow further payments during compliance hold"
|
||||
msgstr "コンプライアンス保留の間、追加の支払いを許可する"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/payment.py
|
||||
msgid ""
|
||||
@@ -30889,20 +30916,16 @@ msgid ""
|
||||
"attempts during that window. This might result in them being charged twice "
|
||||
"if the original payment is approved."
|
||||
msgstr ""
|
||||
"PayPalの不正防止は、個々の支払いの処理をかなりの期間ブロックする可能性があり"
|
||||
"ます。この期間中、支払いは「保留中」とマークされています。顧客がその期間中に"
|
||||
"別の支払い試行を開始できるように許可することができます。元の支払いが承認され"
|
||||
"た場合、二重に請求される可能性があります。"
|
||||
|
||||
#: pretix/plugins/paypal2/payment.py
|
||||
msgid "Timeout further payment attempts"
|
||||
msgstr "追加の支払い試行がタイムアウト"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/payment.py
|
||||
msgid ""
|
||||
"Time duration in minutes after which another payment attempt is possible, "
|
||||
"while the last payment is still under investigation."
|
||||
msgstr "最後の支払いがまだ調査中である間、別の支払い試行が可能となる時間(分)。"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/plugins/paypal2/payment.py
|
||||
msgid "-- Automatic --"
|
||||
@@ -31167,10 +31190,6 @@ msgid ""
|
||||
"twice in case PayPal allows your initial payment attempt. Please contact us "
|
||||
"to resolve this case."
|
||||
msgstr ""
|
||||
"支払いはPayPalで処理されています。通常より時間がかかります。PayPalが支払いを"
|
||||
"確認するまでお待ちいただくか、こちらまたは別の支払い方法で再度お支払いをお試"
|
||||
"しください。PayPalが最初の支払い試行を許可した場合、二重に請求される可能性が"
|
||||
"あります。この件を解決するために、弊社までご連絡ください。"
|
||||
|
||||
#: pretix/plugins/paypal2/views.py
|
||||
msgid ""
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-09-02 08:35+0000\n"
|
||||
"PO-Revision-Date: 2026-03-30 21:00+0000\n"
|
||||
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
|
||||
"pretix/pretix/pt_BR/>\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 5.16.2\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -93,7 +93,7 @@ msgstr "Hebraico"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Hungarian"
|
||||
msgstr "Húngaro"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Indonesian"
|
||||
@@ -153,7 +153,7 @@ msgstr "Espanhol (América latina)"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Thai"
|
||||
msgstr "Tailandês"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Turkish"
|
||||
@@ -3022,12 +3022,11 @@ msgid ""
|
||||
"The field \"%(label)s\" may not contain special characters such as "
|
||||
"\"%(chars)s\"."
|
||||
msgstr ""
|
||||
"O campo \"%(label)s\" não pode conter caracteres especiais como %(chars)s\"."
|
||||
|
||||
#: pretix/base/forms/questions.py
|
||||
#, python-format
|
||||
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
|
||||
msgstr "O campo \"%(label)s\" não pode conter uma URL (%(url)s)."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/forms/questions.py
|
||||
msgctxt "phonenumber"
|
||||
@@ -8174,7 +8173,7 @@ msgstr "Permitida"
|
||||
#: pretix/base/permissions.py
|
||||
msgctxt "permission_level"
|
||||
msgid "Access existing events"
|
||||
msgstr "Acessar eventos existentes"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/base/permissions.py
|
||||
msgctxt "permission_level"
|
||||
@@ -13608,15 +13607,11 @@ msgstr "Por favor, continue em uma nova aba"
|
||||
#: pretix/base/templates/pretixbase/framebreak.html
|
||||
msgid "For security reasons, the following step is only possible in a new tab."
|
||||
msgstr ""
|
||||
"Por razões de segurança, o passo seguinte só é possível de ser feito em uma "
|
||||
"nova aba."
|
||||
|
||||
#: pretix/base/templates/pretixbase/framebreak.html
|
||||
msgid ""
|
||||
"If the new tab did not open automatically, please click the following button:"
|
||||
msgstr ""
|
||||
"Se uma nova aba não abrir automaticamente, por favor, clique no botão a "
|
||||
"seguir:"
|
||||
|
||||
#: pretix/base/templates/pretixbase/framebreak.html
|
||||
#: pretix/presale/templates/pretixpresale/event/cookies.html
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
|
||||
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
|
||||
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
|
||||
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
|
||||
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -19170,9 +19170,8 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position_change.html
|
||||
#, fuzzy
|
||||
msgid "Continue"
|
||||
msgstr "Fortsätt"
|
||||
msgstr "Fortsätta"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
|
||||
msgid "Authorize an application"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-06 15:52+0000\n"
|
||||
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
|
||||
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
|
||||
"PO-Revision-Date: 2026-08-09 06:00+0000\n"
|
||||
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
|
||||
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
"js/sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.9\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
|
||||
msgid "Marked as paid"
|
||||
@@ -133,9 +133,8 @@ msgstr "Mercado Pago"
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js
|
||||
#, fuzzy
|
||||
msgid "Continue"
|
||||
msgstr "Fortsätt"
|
||||
msgstr "Fortsätta"
|
||||
|
||||
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js
|
||||
|
||||
@@ -55,6 +55,7 @@ from django.db.models.functions import Cast, Coalesce
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
from pypdf import PageObject, PdfReader, PdfWriter, Transformation
|
||||
from pypdf.generic import RectangleObject
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.units import inch, mm
|
||||
from reportlab.pdfgen import canvas
|
||||
@@ -237,8 +238,15 @@ def _render_nup_page(nup_pdf: PdfWriter, input_pages: PageObject, opt: dict) ->
|
||||
di = i % badges_per_page
|
||||
tx = opt['margins'][3] + (di % opt['cols']) * opt['offsets'][0]
|
||||
ty = opt['margins'][2] + (opt['rows'] - 1 - (di // opt['cols'])) * opt['offsets'][1]
|
||||
page.add_transformation(Transformation().translate(tx, ty))
|
||||
page.mediabox = RectangleObject((
|
||||
Decimal('%.5f' % (page.mediabox.left.as_numeric() + tx)),
|
||||
Decimal('%.5f' % (page.mediabox.bottom.as_numeric() + ty)),
|
||||
Decimal('%.5f' % (page.mediabox.right.as_numeric() + tx)),
|
||||
Decimal('%.5f' % (page.mediabox.top.as_numeric() + ty))
|
||||
))
|
||||
page.trimbox = page.cropbox = page.mediabox
|
||||
nup_page.merge_transformed_page(page, Transformation().translate(tx, ty))
|
||||
nup_page.merge_page(page)
|
||||
return nup_page
|
||||
|
||||
|
||||
|
||||
@@ -55,8 +55,6 @@ from django_countries.fields import Country
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import simpleSplit
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import PageBreak, Spacer, Table, TableStyle
|
||||
|
||||
@@ -228,20 +226,10 @@ class ReportlabExportMixin:
|
||||
def page_header(self, canvas, doc):
|
||||
from reportlab.lib.units import mm
|
||||
|
||||
font_name = 'OpenSans'
|
||||
font_size = 10
|
||||
left_string = self.get_left_header_string()
|
||||
right_string = self.get_right_header_string()
|
||||
right_width = stringWidth(right_string, font_name, font_size)
|
||||
max_left_width = self.pagesize[0] - doc.leftMargin - doc.rightMargin - right_width - 5 * mm
|
||||
left_string_lines = simpleSplit(left_string, font_name, font_size, max_left_width)
|
||||
if len(left_string_lines) > 1:
|
||||
left_string = left_string_lines[0] + " …"
|
||||
|
||||
canvas.setFont(font_name, font_size)
|
||||
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, left_string)
|
||||
canvas.setFont('OpenSans', 10)
|
||||
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, self.get_left_header_string())
|
||||
canvas.drawRightString(self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 15 * mm,
|
||||
right_string)
|
||||
self.get_right_header_string())
|
||||
canvas.setStrokeColorRGB(0, 0, 0)
|
||||
canvas.line(doc.leftMargin, self.pagesize[1] - 17 * mm,
|
||||
self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 17 * mm)
|
||||
|
||||
@@ -355,9 +355,9 @@ class OrderSendView(BaseSenderView):
|
||||
statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True)
|
||||
orders = qs.filter(statusq)
|
||||
|
||||
opq = OrderPosition.objects.with_scopes_disabled().filter(
|
||||
opq = OrderPosition.objects.filter(
|
||||
Q(item_id__in=[i.pk for i in form.cleaned_data.get('items')]) | Q(Exists(
|
||||
OrderPosition.objects.with_scopes_disabled().filter(
|
||||
OrderPosition.objects.filter(
|
||||
addon_to_id=OuterRef('pk'),
|
||||
item_id__in=[i.pk for i in form.cleaned_data.get('items')]
|
||||
)
|
||||
|
||||
@@ -248,7 +248,19 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
class RecomputeMixin:
|
||||
def recompute_cart(self):
|
||||
cm = CartManager(
|
||||
event=self.request.event,
|
||||
cart_id=get_or_create_cart_id(self.request),
|
||||
invoice_address=self.invoice_address,
|
||||
sales_channel=self.request.sales_channel,
|
||||
customer=self.cart_customer,
|
||||
)
|
||||
return cm.recompute_final_prices_and_taxes()
|
||||
|
||||
|
||||
class CustomerStep(CartMixin, RecomputeMixin, TemplateFlowStep):
|
||||
priority = 45
|
||||
identifier = "customer"
|
||||
template_name = "pretixpresale/event/checkout_customer.html"
|
||||
@@ -326,28 +338,34 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
customer_login(self.request, customer)
|
||||
return True
|
||||
|
||||
def _proceed_to_next(self):
|
||||
diff = self.recompute_cart()
|
||||
if abs(diff) > Decimal('0.001'):
|
||||
messages.info(self.request, _('Due to the information you entered, the total of your cart has changed.'))
|
||||
return redirect_to_url(self.get_next_url(self.request))
|
||||
|
||||
def post(self, request):
|
||||
self.request = request
|
||||
|
||||
if request.POST.get("customer_mode") == 'login':
|
||||
if self.cart_session.get('customer'):
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
elif request.customer:
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
self.cart_session['customer'] = request.customer.pk
|
||||
self.cart_session['customer_cart_tied_to_login'] = True
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
elif self.request.POST.get("login-sso-data"):
|
||||
if not self._handle_sso_login():
|
||||
messages.error(request, _('We failed to process your authentication request, please try again.'))
|
||||
return self.render()
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
elif self.event.settings.customer_accounts_native and self.login_form.is_valid():
|
||||
customer_login(self.request, self.login_form.get_customer())
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
self.cart_session['customer'] = self.login_form.get_customer().pk
|
||||
self.cart_session['customer_cart_tied_to_login'] = True
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
else:
|
||||
return self.render()
|
||||
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
|
||||
@@ -356,13 +374,13 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
||||
self.cart_session['customer_mode'] = 'login'
|
||||
self.cart_session['customer'] = customer.pk
|
||||
self.cart_session['customer_cart_tied_to_login'] = False
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
else:
|
||||
return self.render()
|
||||
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
|
||||
self.cart_session['customer'] = None
|
||||
self.cart_session['customer_mode'] = 'guest'
|
||||
return redirect_to_url(self.get_next_url(request))
|
||||
return self._proceed_to_next()
|
||||
else:
|
||||
return self.render()
|
||||
|
||||
@@ -770,12 +788,18 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
|
||||
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
|
||||
|
||||
if 'customer_mode' in self.cart_session:
|
||||
cart_customer = self.cart_customer
|
||||
else:
|
||||
cart_customer = getattr(self.request, 'customer', None)
|
||||
|
||||
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
|
||||
invoice_address=self.invoice_address.pk, locale=get_language(),
|
||||
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
|
||||
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None),
|
||||
customer=cart_customer.pk if cart_customer else None)
|
||||
|
||||
|
||||
class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateFlowStep):
|
||||
priority = 50
|
||||
identifier = "questions"
|
||||
template_name = "pretixpresale/event/checkout_questions.html"
|
||||
@@ -986,6 +1010,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
def post(self, request):
|
||||
self.request = request
|
||||
failed = not self.save() or not self.contact_form.is_valid()
|
||||
recomputed = False
|
||||
if self.address_asked or self.request.event.settings.invoice_name_required:
|
||||
failed = failed or not self.invoice_form.is_valid()
|
||||
if failed:
|
||||
@@ -1030,13 +1055,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
self.cart_session['saved_invoice_address'] = saved.pk
|
||||
|
||||
try:
|
||||
cm = CartManager(
|
||||
event=self.request.event,
|
||||
cart_id=get_or_create_cart_id(request),
|
||||
invoice_address=addr,
|
||||
sales_channel=request.sales_channel,
|
||||
)
|
||||
diff = cm.recompute_final_prices_and_taxes()
|
||||
diff = self.recompute_cart()
|
||||
recomputed = True
|
||||
except TaxRule.SaleNotAllowed:
|
||||
messages.error(request,
|
||||
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
|
||||
@@ -1045,15 +1065,23 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
|
||||
self.cart_session['invoice_address'] = addr.pk
|
||||
if abs(diff) > Decimal('0.001'):
|
||||
messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax '
|
||||
'rate to your purchase and the price of the products in your cart has '
|
||||
'changed accordingly.'))
|
||||
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
|
||||
'have been changed and the price of the products in your cart has changed '
|
||||
'accordingly.'))
|
||||
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
|
||||
elif 'invoice_address' in self.cart_session:
|
||||
# Invoice address was there, but is no longer asked for
|
||||
self.invoice_address.delete()
|
||||
del self.cart_session['invoice_address']
|
||||
|
||||
if self.cart_customer and not recomputed and any(cp.item.validity_dynamic_start_choice for cp in self.positions):
|
||||
# Discounts might have changed
|
||||
diff = self.recompute_cart()
|
||||
if abs(diff) > Decimal('0.001'):
|
||||
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
|
||||
'have been changed and the price of the products in your cart has changed '
|
||||
'accordingly.'))
|
||||
|
||||
try:
|
||||
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,
|
||||
testmode=self.request.event.testmode, valid_from_not_chosen=False)
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
#
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import (
|
||||
@@ -29,11 +30,13 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.lookups import Exact
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
|
||||
Discount, Event, Item, ItemVariation, Membership, Quota, SalesChannel,
|
||||
SeatCategoryMapping, SubEvent, Voucher,
|
||||
)
|
||||
from pretix.base.models.items import (
|
||||
Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
|
||||
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
@@ -54,10 +57,44 @@ def item_group_by_category(items):
|
||||
)
|
||||
|
||||
|
||||
def _single_item_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||
subevent: SubEvent=None, voucher: Voucher=None, is_addons=False,
|
||||
memberships: List[Membership]=None) -> List[Discount]:
|
||||
if memberships is not None:
|
||||
prefetch_membership_types = ['require_membership_types']
|
||||
else:
|
||||
prefetch_membership_types = []
|
||||
discount_qs = event.discounts.filter(
|
||||
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
|
||||
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel),
|
||||
Q(active=True),
|
||||
# Only discounts that can be applied before we know the full cart
|
||||
Q(benefit_only_apply_to_cheapest_n_matches__isnull=True) | Q(benefit_only_apply_to_cheapest_n_matches=1),
|
||||
benefit_same_products=True,
|
||||
condition_min_value=Decimal("0.00"),
|
||||
condition_min_count=1,
|
||||
).prefetch_related('condition_limit_products', *prefetch_membership_types).order_by('position', 'pk')
|
||||
|
||||
if subevent:
|
||||
discount_qs = discount_qs.filter(
|
||||
Q(subevent_date_from__isnull=True) | Q(subevent_date_from__lte=subevent.date_from),
|
||||
Q(subevent_date_until__isnull=True) | Q(subevent_date_until__gte=subevent.date_from),
|
||||
)
|
||||
|
||||
if is_addons:
|
||||
discount_qs = discount_qs.filter(condition_apply_to_addons=True, benefit_apply_to_addons=True)
|
||||
|
||||
if voucher and voucher.price_mode != "none":
|
||||
discount_qs = discount_qs.filter(condition_ignore_voucher_discounted=False, benefit_ignore_voucher_discounted=False)
|
||||
|
||||
return list(discount_qs)
|
||||
|
||||
|
||||
def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
|
||||
allow_addons=False, allow_cross_sell=False,
|
||||
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
|
||||
ignore_hide_sold_out_for_item_ids=None):
|
||||
ignore_hide_sold_out_for_item_ids=None, _discount_cache=None):
|
||||
base_qs_set = base_qs is not None
|
||||
base_qs = base_qs if base_qs is not None else event.items
|
||||
|
||||
@@ -190,6 +227,24 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
|
||||
if filter_categories:
|
||||
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
|
||||
|
||||
# We pre-computate discounts that do not rely on specific combinations in the cart.
|
||||
# This is not the same order of operations that is applied in the cart, so there could be some differences
|
||||
# when it comes to tax rate handling, but we don't have that information in the product list anyway, so
|
||||
# that is acceptable.
|
||||
cache_key = (event, channel, voucher, subevent, allow_addons)
|
||||
if _discount_cache is None:
|
||||
_discount_cache = {}
|
||||
if cache_key not in _discount_cache:
|
||||
_discount_cache[cache_key] = list(_single_item_discounts(
|
||||
event=event,
|
||||
sales_channel=channel,
|
||||
voucher=voucher,
|
||||
subevent=subevent,
|
||||
is_addons=allow_addons,
|
||||
memberships=memberships,
|
||||
))
|
||||
discounts = _discount_cache[cache_key]
|
||||
|
||||
display_add_to_cart = False
|
||||
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
|
||||
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
|
||||
@@ -272,6 +327,11 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
|
||||
if resp:
|
||||
item.description += ("<br/>" if item.description else "") + resp
|
||||
|
||||
matching_discounts = [
|
||||
d for d in discounts
|
||||
if d.condition_matches_product(item) and d.valid_for_memberships(memberships, event, subevent, item)
|
||||
]
|
||||
|
||||
if not item.has_variations:
|
||||
item._remove = False
|
||||
if not bool(item._subevent_quotas):
|
||||
@@ -299,30 +359,51 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
|
||||
max_per_order
|
||||
)
|
||||
|
||||
original_price = item_price_override.get(item.pk, item.default_price)
|
||||
configured_price = item_price_override.get(item.pk, item.default_price)
|
||||
voucher_reduced = False
|
||||
if voucher:
|
||||
price = voucher.calculate_price(original_price)
|
||||
voucher_reduced = price < original_price
|
||||
price = voucher.calculate_price(configured_price)
|
||||
voucher_reduced = price < configured_price
|
||||
include_bundled = not voucher.all_bundles_included
|
||||
else:
|
||||
price = original_price
|
||||
price = configured_price
|
||||
include_bundled = True
|
||||
|
||||
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
if matching_discounts and not item.free_price:
|
||||
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
|
||||
# accept it not being handled here.
|
||||
discount = matching_discounts[0] # First matching discount rule always wins
|
||||
# First handle taxes because discount is always computed on gross
|
||||
taxed_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
|
||||
discounted_price = round_decimal(
|
||||
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
|
||||
event.currency,
|
||||
)
|
||||
item.display_price = item.tax(discounted_price, currency=event.currency, include_bundled=include_bundled,
|
||||
base_price_is='gross')
|
||||
else:
|
||||
discount = None
|
||||
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
|
||||
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
||||
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
|
||||
else:
|
||||
item.suggested_price = item.display_price
|
||||
|
||||
if price != original_price:
|
||||
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
|
||||
else:
|
||||
item.original_price = (
|
||||
item.tax(item.original_price, currency=event.currency, include_bundled=True,
|
||||
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
||||
if item.original_price else None
|
||||
if voucher_reduced or (discount and not item.original_price):
|
||||
# If a voucher is used, we use the non-voucher price as the "original price", even if a different
|
||||
# original price is set, to highlight the voucher's impact. We also use the configured price as
|
||||
# original price if a discount is applied and no explicit original price is set
|
||||
item.original_price = item.tax(configured_price, currency=event.currency, include_bundled=True)
|
||||
elif item.original_price:
|
||||
item.original_price = item.tax(
|
||||
item.original_price, currency=event.currency, include_bundled=True,
|
||||
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
|
||||
)
|
||||
else:
|
||||
item.original_price = None
|
||||
|
||||
if not display_add_to_cart:
|
||||
display_add_to_cart = not item.requires_seat and item.order_max > 0
|
||||
else:
|
||||
@@ -352,35 +433,53 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
|
||||
max_per_order
|
||||
)
|
||||
|
||||
original_price = var_price_override.get(var.pk, var.price)
|
||||
configured_price = var_price_override.get(var.pk, var.price)
|
||||
voucher_reduced = False
|
||||
if voucher:
|
||||
price = voucher.calculate_price(original_price)
|
||||
voucher_reduced = price < original_price
|
||||
price = voucher.calculate_price(configured_price)
|
||||
voucher_reduced = price < configured_price
|
||||
include_bundled = not voucher.all_bundles_included
|
||||
else:
|
||||
price = original_price
|
||||
price = configured_price
|
||||
include_bundled = True
|
||||
|
||||
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
if matching_discounts and not item.free_price:
|
||||
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
|
||||
# accept it not being handled here.
|
||||
discount = matching_discounts[0] # First matching discount rule always wins
|
||||
# First handle taxes because discount is always computed on gross
|
||||
taxed_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
|
||||
discounted_price = round_decimal(
|
||||
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
|
||||
event.currency,
|
||||
)
|
||||
var.display_price = var.tax(discounted_price, currency=event.currency,
|
||||
include_bundled=include_bundled,
|
||||
base_price_is='gross')
|
||||
else:
|
||||
discount = None
|
||||
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||
|
||||
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
|
||||
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
|
||||
include_bundled=include_bundled)
|
||||
var.suggested_price = var.tax(max(price, var.free_price_suggestion), currency=event.currency,
|
||||
include_bundled=include_bundled)
|
||||
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
||||
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
|
||||
include_bundled=include_bundled)
|
||||
var.suggested_price = var.tax(max(price, item.free_price_suggestion), currency=event.currency,
|
||||
include_bundled=include_bundled)
|
||||
else:
|
||||
var.suggested_price = var.display_price
|
||||
|
||||
if price != original_price:
|
||||
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
|
||||
if voucher_reduced or (discount and not item.original_price and not var.original_price):
|
||||
var.original_price = var.tax(configured_price, currency=event.currency, include_bundled=True)
|
||||
elif item.original_price or var.original_price:
|
||||
var.original_price = var.tax(
|
||||
var.original_price or item.original_price, currency=event.currency,
|
||||
include_bundled=True,
|
||||
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
|
||||
)
|
||||
else:
|
||||
var.original_price = (
|
||||
var.tax(var.original_price or item.original_price, currency=event.currency,
|
||||
include_bundled=True,
|
||||
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
||||
) if var.original_price or item.original_price else None
|
||||
var.original_price = None
|
||||
|
||||
var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load escapejson %}
|
||||
|
||||
{% if payment_qr_codes %}
|
||||
<div class="tabcontainer col-md-6 col-sm-6 col-xs-12 text-center js-only blank-after">
|
||||
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
|
||||
<div id="banktransfer_qrcodes_tabs_content" class="tabpanels blank-after">
|
||||
{% for code_info in payment_qr_codes %}
|
||||
<div id="banktransfer_qrcodes_{{ code_info.id }}"
|
||||
|
||||
@@ -58,7 +58,8 @@ from django.views.generic import TemplateView, View
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.models import (
|
||||
CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
|
||||
CartPosition, Customer, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent,
|
||||
Voucher,
|
||||
)
|
||||
from pretix.base.services.cart import (
|
||||
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
|
||||
@@ -153,6 +154,17 @@ class CartActionMixin:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
return InvoiceAddress()
|
||||
|
||||
@cached_property
|
||||
def cart_customer(self):
|
||||
if 'customer_mode' in self.cart_session:
|
||||
if self.cart_session['customer_mode'] == 'login':
|
||||
try:
|
||||
return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
|
||||
except Customer.DoesNotExist:
|
||||
return
|
||||
else:
|
||||
return getattr(self.request, 'customer', None)
|
||||
|
||||
|
||||
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
|
||||
if value.strip() == '' or '_' not in key:
|
||||
@@ -505,7 +517,7 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
|
||||
return self.do(self.request.event.id, code, get_or_create_cart_id(self.request),
|
||||
translation.get_language(), request.sales_channel.identifier,
|
||||
time_machine_now(default=None))
|
||||
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
|
||||
else:
|
||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||
return JsonResponse({
|
||||
@@ -532,7 +544,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
try:
|
||||
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
|
||||
translation.get_language(), request.sales_channel.identifier,
|
||||
time_machine_now(default=None))
|
||||
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
|
||||
except ValueError:
|
||||
return redirect_to_url(self.get_error_url())
|
||||
else:
|
||||
@@ -555,7 +567,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
|
||||
request.sales_channel.identifier, time_machine_now(default=None))
|
||||
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
|
||||
|
||||
|
||||
@method_decorator(allow_cors_if_namespaced, 'dispatch')
|
||||
@@ -591,7 +603,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
|
||||
request.sales_channel.identifier, time_machine_now(default=None))
|
||||
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
|
||||
|
||||
|
||||
@method_decorator(allow_cors_if_namespaced, 'dispatch')
|
||||
@@ -648,7 +660,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
if items:
|
||||
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
|
||||
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
|
||||
time_machine_now(default=None))
|
||||
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
|
||||
else:
|
||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||
return JsonResponse({
|
||||
|
||||
@@ -122,20 +122,19 @@ def widget_css_etag(request, version, **kwargs):
|
||||
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
|
||||
|
||||
|
||||
# use vite by default, serve old vue2-based widget only for widget_vue2_origins
|
||||
def _use_vite(request):
|
||||
if getattr(settings, 'PRETIX_WIDGET_VUE', False) or "legacy" in request.GET:
|
||||
return False
|
||||
if getattr(settings, 'PRETIX_WIDGET_VITE', False) or "beta" in request.GET:
|
||||
return True
|
||||
origin = request.META.get('HTTP_ORIGIN', '')
|
||||
gs = GlobalSettingsObject()
|
||||
vue_origins = gs.settings.get('widget_vue2_origins', as_type=str, default='')
|
||||
if vue_origins and not origin:
|
||||
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
|
||||
if vite_origins and not origin:
|
||||
referer = request.META.get('HTTP_REFERER', '')
|
||||
origin = '/'.join(referer.split('/', 3)[:3])
|
||||
if origin and vue_origins:
|
||||
origins_list = [o.strip() for o in vue_origins.strip().splitlines() if o.strip()]
|
||||
return origin not in origins_list
|
||||
return True
|
||||
if origin and vite_origins:
|
||||
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
|
||||
return origin in origins_list
|
||||
return False
|
||||
|
||||
|
||||
def widget_js_etag(request, version, lang, **kwargs):
|
||||
|
||||
@@ -913,7 +913,7 @@ VITE_DEV_SERVER_PORT = 5173
|
||||
VITE_DEV_SERVER = f"http://localhost:{VITE_DEV_SERVER_PORT}"
|
||||
VITE_DEV_MODE = DEBUG
|
||||
VITE_IGNORE = False # Used to ignore `collectstatic`/`rebuild`
|
||||
PRETIX_WIDGET_VUE = os.environ.get('PRETIX_WIDGET_VUE', '') not in ('', '0')
|
||||
PRETIX_WIDGET_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
|
||||
|
||||
if DEBUG:
|
||||
# Reload if settings file changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('a[href^="mailto:"]').forEach(function (link) {
|
||||
// Replace [at] with @ and the [dot] with . in both the href and the displayed text (if needed)
|
||||
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
|
||||
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
|
||||
link.href = link.href.replace('[at]', '@').replace('[dot]', '.')
|
||||
link.textContent = link.textContent.replace('[at]', '@').replace('[dot]', '.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -233,7 +233,6 @@ let form_handlers = function (el) {
|
||||
|
||||
function setup_basics (el) {
|
||||
el.find('form').attr('novalidate', true).on('submit', function (e) {
|
||||
if (e.originalEvent && e.originalEvent.submitter && e.originalEvent.submitter.formNoValidate) return;
|
||||
if (!this.checkValidity()) {
|
||||
let input = this.querySelector(':invalid:not(fieldset)');
|
||||
(input.labels[0] || input).scrollIntoView()
|
||||
|
||||
@@ -514,6 +514,10 @@ export function createWidgetStore (config: {
|
||||
// button with subevent but no items
|
||||
redirectUrl += `${this.subevent}/`
|
||||
}
|
||||
if (this.subevent && !this.cartId) {
|
||||
// button with subevent but no items
|
||||
redirectUrl += `${this.subevent}/`
|
||||
}
|
||||
redirectUrl += `?iframe=1&locale=${LANG}`
|
||||
if (this.cartId) {
|
||||
// ajax to make sure the cart-id is used, even if the cart is currently empty
|
||||
|
||||
@@ -46,6 +46,8 @@ TEST_DISCOUNT_RES = {
|
||||
"sales_channels": ["bar", "baz", "web"],
|
||||
"available_from": None,
|
||||
"available_until": None,
|
||||
"require_membership_hidden": False,
|
||||
"require_membership_types": [],
|
||||
"subevent_mode": "mixed",
|
||||
"subevent_date_from": None,
|
||||
"subevent_date_until": None,
|
||||
|
||||
@@ -749,7 +749,7 @@ def test_query_count_many_items(event, itemcount):
|
||||
''',
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
''',
|
||||
expect_num_queries=8,
|
||||
expect_num_queries=9,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -763,7 +763,7 @@ def test_query_count_many_items(event, itemcount):
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
Tickets Ticket 2 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=9,
|
||||
expect_num_queries=10,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -779,7 +779,7 @@ def test_query_count_many_items(event, itemcount):
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
Tickets Ticket 2 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=9,
|
||||
expect_num_queries=10,
|
||||
)
|
||||
|
||||
|
||||
@@ -803,7 +803,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
||||
''',
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
''',
|
||||
expect_num_queries=8,
|
||||
expect_num_queries=9,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -817,7 +817,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=9,
|
||||
expect_num_queries=10,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -833,7 +833,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=9,
|
||||
expect_num_queries=10,
|
||||
)
|
||||
|
||||
|
||||
@@ -857,7 +857,7 @@ def test_query_count_many_cartpos(event, catcount):
|
||||
''',
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
''',
|
||||
expect_num_queries=8,
|
||||
expect_num_queries=9,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -871,7 +871,7 @@ def test_query_count_many_cartpos(event, catcount):
|
||||
recommendations=''' Price Discounted Price Max Count Prefix
|
||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=9,
|
||||
expect_num_queries=10,
|
||||
)
|
||||
check_cart_behaviour(
|
||||
event,
|
||||
@@ -893,5 +893,5 @@ def test_query_count_many_cartpos(event, catcount):
|
||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||
Category 2 Ticket 2-B 42.00 0.00 1 -
|
||||
''',
|
||||
expect_num_queries=13,
|
||||
expect_num_queries=14,
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ from django.template import Context, Template
|
||||
from django.test import RequestFactory
|
||||
from django.utils import translation
|
||||
|
||||
from pretix.base.templatetags.money import money_filter, tax_rate_format
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
TEMPLATE_REPLACE_PAGE = Template(
|
||||
"{% load urlreplace %}{% url_replace request 'page' 3 %}"
|
||||
@@ -60,9 +60,7 @@ def test_urlreplace_replace_parameter():
|
||||
"locale,amount,currency,expected",
|
||||
[
|
||||
("en", None, "USD", "$0.00"),
|
||||
("en", "", "USD", ""),
|
||||
("en", 1000000, "USD", "$1,000,000.00"),
|
||||
("en", 2.23, "USD", "$2.23"),
|
||||
("en", Decimal("1000.00"), "USD", "$1,000.00"),
|
||||
("de", Decimal("1.23"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1000.00"), "EUR", "1.000,00" + NBSP + "€"),
|
||||
@@ -72,14 +70,11 @@ def test_urlreplace_replace_parameter():
|
||||
|
||||
# unknown currency
|
||||
("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"),
|
||||
("de", Decimal("1234.567"), "FOO", "1.234,567" + NBSP + "FOO"),
|
||||
("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"),
|
||||
|
||||
# deal with precision that is higher than the currency
|
||||
("de", Decimal("1.234"), "EUR", "1,234" + NBSP + "€"),
|
||||
("de", 1.234, "EUR", "1,234" + NBSP + "€"),
|
||||
("de", Decimal("1.2340"), "EUR", "1,234" + NBSP + "€"),
|
||||
("de", Decimal("1.2300"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,10" + NBSP + "¥"),
|
||||
# rounding errors
|
||||
("de", Decimal("1.234"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1023.1"), "JPY", "JPY 1.023,10"),
|
||||
]
|
||||
)
|
||||
def test_money_filter(locale, amount, currency, expected):
|
||||
@@ -103,31 +98,9 @@ def test_money_filter(locale, amount, currency, expected):
|
||||
[
|
||||
("de", Decimal("1000.00"), "EUR", "1.000,00"),
|
||||
("en", Decimal("1000.00"), "EUR", "1,000.00"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,1"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,10"),
|
||||
]
|
||||
)
|
||||
def test_money_filter_hidecurrency(locale, amount, currency, expected):
|
||||
translation.activate(locale)
|
||||
assert money_filter(amount, currency, hide_currency=True) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"locale,rate,expected",
|
||||
[
|
||||
("de", Decimal("2.00"), "2"),
|
||||
("de", Decimal("2.50"), "2,5"),
|
||||
("de", Decimal("2.2340"), "2,234"),
|
||||
("en", Decimal("2.00"), "2"),
|
||||
("en", Decimal("2.50"), "2.5"),
|
||||
("en", Decimal("4.3e7"), "43000000"),
|
||||
("en", Decimal("2.2340"), "2.234"),
|
||||
("en", "2.23", "2.23"),
|
||||
("en", 2.23, "2.23"),
|
||||
("en", 2, "2"),
|
||||
("en", "", ""),
|
||||
("en", None, "0"),
|
||||
]
|
||||
)
|
||||
def test_tax_rate_format(locale, rate, expected):
|
||||
translation.activate(locale)
|
||||
assert tax_rate_format(rate) == expected
|
||||
|
||||
@@ -128,9 +128,9 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
|
||||
doc = self.client.get('/control/event/%s/%s/vouchers/?download=yes' % (self.orga.slug, self.event.slug))
|
||||
assert doc.content.decode().strip() == '"Voucher code","Valid until","Product","Reserve quota",' \
|
||||
'"Bypass quota","Price effect","Value","Tag","Redeemed",' \
|
||||
'"Maximum usages","Seat","Comment","Budget","Budget used"' \
|
||||
'"Maximum usages","Seat","Comment"' \
|
||||
'\r\n"ABCDEFG","","Early-bird ticket","No","No","No effect","","","0",' \
|
||||
'"1","","","",""'
|
||||
'"1","",""'
|
||||
|
||||
def test_filter_status_valid(self):
|
||||
with scopes_disabled():
|
||||
|
||||
@@ -89,11 +89,11 @@ def _widget_assets():
|
||||
f'Start it with: npm run dev:widget'
|
||||
)
|
||||
yield
|
||||
elif os.environ.get("PRETIX_WIDGET_VUE"):
|
||||
yield # Old widget, no build needed
|
||||
else:
|
||||
elif os.environ.get("PRETIX_WIDGET_VITE"):
|
||||
subprocess.check_call(['npm', 'run', 'build:widget'], cwd=PROJECT_ROOT)
|
||||
yield
|
||||
else:
|
||||
yield # Old widget, no build needed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -155,6 +155,10 @@ def live_server_url(live_server, settings):
|
||||
|
||||
settings.SITE_URL = live_server.url
|
||||
|
||||
# Enable Vite widget if requested via env var
|
||||
if os.environ.get("PRETIX_WIDGET_VITE") or os.environ.get("PRETIX_WIDGET_VITE_DEV"):
|
||||
settings.PRETIX_WIDGET_VITE = True
|
||||
|
||||
return live_server.url
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope
|
||||
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.presale.productlist import prepare_item_list_for_shop
|
||||
|
||||
# Tests for prepare_item_list_for_shop are really incomplete since historically, most features are
|
||||
# tested on the test_event or test_widget layer. We'll slowly add new tests here to test closer to the
|
||||
# source.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event():
|
||||
o = Organizer.objects.create(name='MRMCD', slug='mrmcd')
|
||||
e = Event.objects.create(
|
||||
organizer=o, name='MRMCD2015', slug='2015',
|
||||
date_from=now(), live=True
|
||||
)
|
||||
with scope(organizer=o):
|
||||
yield e
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quota(event):
|
||||
return event.quotas.create(name="Tickets", size=500)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item(event, quota):
|
||||
i = event.items.create(name="Ticket", default_price=Decimal("42.00"))
|
||||
quota.items.add(i)
|
||||
return i
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def variation(event, quota):
|
||||
i = event.items.create(name="Ticket with variants", default_price=Decimal("99.00"))
|
||||
v = i.variations.create(value="Default", default_price=Decimal("42.00"))
|
||||
quota.items.add(i)
|
||||
quota.variations.add(v)
|
||||
return v
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def discount(event):
|
||||
return event.discounts.create(
|
||||
internal_name="Early-Bird-Discount",
|
||||
all_sales_channels=True,
|
||||
available_from=now() - timedelta(days=2),
|
||||
available_until=now() + timedelta(days=2),
|
||||
condition_all_products=True,
|
||||
condition_min_count=1,
|
||||
benefit_discount_matching_percent=Decimal("10.00"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def channel(event):
|
||||
return event.organizer.sales_channels.get(identifier="web")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_default_price(event, item, variation, channel):
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("42")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("42")
|
||||
|
||||
|
||||
def _test_no_discount(event, channel):
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("42")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("42")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_applied(event, item, variation, channel, discount):
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[0].original_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_for_groups_ignored(event, item, variation, channel, discount):
|
||||
discount.condition_min_count = 2
|
||||
discount.save()
|
||||
_test_no_discount(event, channel)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_original_price_kept(event, item, variation, channel, discount):
|
||||
item.original_price = Decimal("46.00")
|
||||
item.save()
|
||||
variation.original_price = Decimal("46.00")
|
||||
variation.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[0].original_price.gross == Decimal("46.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_out_of_timeframe(event, item, variation, channel, discount):
|
||||
discount.available_from = now() + timedelta(days=2)
|
||||
discount.save()
|
||||
_test_no_discount(event, channel)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_wrong_channel(event, item, variation, channel, discount):
|
||||
discount.all_sales_channels = False
|
||||
discount.save()
|
||||
_test_no_discount(event, channel)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discount_benefits_other_products_ignored(event, item, variation, channel, discount):
|
||||
discount.benefit_same_products = False
|
||||
discount.save()
|
||||
_test_no_discount(event, channel)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_for_addons(event, item, variation, channel, discount):
|
||||
discount.condition_apply_to_addons = False
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
|
||||
|
||||
discount.condition_apply_to_addons = True
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_with_voucher(event, item, variation, channel, discount):
|
||||
voucher = event.vouchers.create(code="FOO", price_mode="subtract", value=Decimal("10.00"))
|
||||
voucher2 = event.vouchers.create(code="BAR")
|
||||
discount.condition_ignore_voucher_discounted = True
|
||||
discount.save()
|
||||
item.original_price = Decimal("46.00")
|
||||
item.save()
|
||||
variation.item.original_price = Decimal("46.00")
|
||||
variation.item.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("32.00")
|
||||
assert items[0].original_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("32.00")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher2)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[0].original_price.gross == Decimal("46.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
|
||||
|
||||
discount.condition_ignore_voucher_discounted = False
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("28.80")
|
||||
assert items[0].original_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("28.80")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_for_subevent_timeframe(event, quota, item, variation, channel, discount):
|
||||
event.has_subevents = True
|
||||
event.save()
|
||||
se = event.subevents.create(
|
||||
name="Foobar", date_from=datetime(2028, 12, 27, 10, 0, 0, tzinfo=UTC)
|
||||
)
|
||||
quota.subevent = se
|
||||
quota.save()
|
||||
|
||||
discount.subevent_date_from = se.date_from + timedelta(days=1)
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
|
||||
|
||||
discount.subevent_date_from = se.date_from - timedelta(days=1)
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_for_products(event, quota, item, variation, channel, discount):
|
||||
discount.condition_all_products = False
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
|
||||
|
||||
discount.condition_limit_products.add(item)
|
||||
discount.condition_limit_products.add(variation.item)
|
||||
discount.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("37.80")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_for_products_tax_additive(event, quota, item, variation, channel, discount):
|
||||
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
|
||||
item.tax_rule = tr
|
||||
item.save()
|
||||
variation.item.tax_rule = tr
|
||||
variation.item.save()
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("44.98")
|
||||
assert items[0].original_price.gross == Decimal("49.98")
|
||||
assert items[0].original_price.net == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("44.98")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
|
||||
assert items[1].available_variations[0].original_price.net == Decimal("42.00")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_discounts_for_products_tax_additive_bundle_included(event, quota, item, variation, channel, discount):
|
||||
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
|
||||
b = event.items.create(name="Bundled product", default_price=Decimal("10.00"), tax_rule=tr, require_bundling=True)
|
||||
quota.items.add(b)
|
||||
item.tax_rule = tr
|
||||
item.save()
|
||||
variation.item.tax_rule = tr
|
||||
variation.item.save()
|
||||
item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00"))
|
||||
variation.item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00"))
|
||||
items, _ = prepare_item_list_for_shop(event, channel=channel)
|
||||
assert len(items) == 2
|
||||
assert items[0].display_price.gross == Decimal("45.98")
|
||||
assert items[0].original_price.gross == Decimal("49.98")
|
||||
assert items[0].original_price.net == Decimal("42.00")
|
||||
assert items[1].available_variations[0].display_price.gross == Decimal("45.98")
|
||||
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
|
||||
assert items[1].available_variations[0].original_price.net == Decimal("42.00")
|
||||
Reference in New Issue
Block a user