Compare commits

..
Author SHA1 Message Date
Raphael Michel 6a181506e9 Add reset button 2026-09-18 17:32:00 +02:00
Raphael Michel 84df699503 User details: Show list of 2FA devices 2026-09-18 16:32:27 +02:00
Raphael Michelandpajowu aa14505d2c Money representation in templates: Allow more precision (#6454)
* Money representation in templates: Allow more precision

When rendering money in templates, we used to have the following logic:

- When the decimal places fit the currency, render with Babel
- When they don't, e.g. we stored 123.67 JPY, even though there are no
  fractional Yens, render without Babel with a custom format, but render
  the fractional Yens because we'd rather *show* wrong data and make the
  bug obvious than hide it.

However, we only did that up to a prevision of two places, we never
showed more. This is still sufficient for core pretix, but we have
plugins that need to operate in fractional cents. Also, we CAN render
everything through babel for consistent formatting.

There is one **risk**: This might cause weird results on SQLite. Since
SQLite has no concept of precise decimal math, results of in-SQL
computations can sometimes experience floating point errors and show
with A LOT of decimal palces. This used to be invisible since the UI
performed the rounding. With this PR – not any more. We'll need to see
how annoying it is, but it should only affect development mode.

This PR also fixes a bug in tax_rate_format that for some reason did not
do what it was supposed to do, even though I tested it back then, weird.
Might even be a Python version thing?

* Update src/pretix/base/templatetags/money.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Apply suggestion from @pajowu

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Fix typing stuff

* Fix precision issue

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-18 11:49:12 +02:00
Kara Engelhardt df69656364 PDF: Fix background page being rotated multiple times
transfer_rotation_to_content (or rather add_transformation/replace_content) do not properly duplicate the contentstream they're modifying. this means that if you pass a pdf-level copy of the same bg_page multiple times, the content will be rotated multiple times but the rotation-property ('/Rotate') will only be changed for the first page

pypdfs docs explicitly say that you should not to add_transformation + merge, but instead use merge_transformed_page, which this now does for everything
2026-09-18 11:35:01 +02:00
Kara Engelhardt 82cffd1519 pdf: calculate sizebox based on cropbox and mediabox 2026-09-18 11:33:34 +02:00
Richard Schreiber 7fc527135d PDF: use artbox before trimbox before mediabox from background-PDF as size for canvas 2026-09-18 11:33:34 +02:00
Raphael Michel 8e39b67ee9 Fix missing translatable field label 2026-09-17 11:56:12 +02:00
dependabot[bot] 289cbe5bfc Update django-countries requirement from ==9.0.* to ==9.1.* (#6562)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.md)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: django-countries
  dependency-version: 9.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 10:43:55 +02:00
Raphael Michel 7143b877c8 Translations: Update German
Currently translated at 100.0% (6419 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-09-16 15:46:28 +02:00
dependabot[bot] e2a78100af Update pyjwt requirement from ==2.13.* to ==2.14.* (#6557)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.13.0...2.14.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.14.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:16 +02:00
dependabot[bot] ca77467e48 Update sentry-sdk requirement from ==2.68.* to ==2.69.* (#6556)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.68.0...2.69.1)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.69.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:07 +02:00
Raphael Michel d3cbfcd4da Invoice export: Add transmission-specific data (Z#23239732) (#6522) 2026-09-15 16:44:00 +02:00
Richard Schreiber 3648ef28d7 Fix deanonymizing emails with multiple dots (#6559) 2026-09-15 13:16:36 +02:00
Richard Schreiber 689b99c3aa PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter (#6555)
* PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter

* fix pdf_header

* fix flake8
2026-09-14 15:45:54 +02:00
Raphael Michel 6c40daa863 Hotfix for JS issue when submit() is called from JS 2026-09-14 15:33:25 +02:00
dependabot[bot]andRaphael Michel efdc83f72a Update pypdf requirement from ==6.5.* to ==6.11.* (#6176)
* Update pypdf requirement from ==6.5.* to ==6.11.*

Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.5.0...6.11.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.11.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Try to adjust code

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-09-14 14:09:28 +02:00
dependabot[bot] 8b10534c74 Bump smol-toml from 1.6.1 to 1.7.1 (#6546)
Bumps [smol-toml](https://github.com/squirrelchat/smol-toml) from 1.6.1 to 1.7.1.
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.1...v1.7.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.7.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 10:31:12 +02:00
Raphael Michel bcacff2ee7 Voucher list: Add budget and budget used to CSV export (Z#23237857) (#6520)
* Voucher list: Add budget and budget used to CSV export (Z#23237857)

* Fix failing test
2026-09-14 10:31:07 +02:00
Kara Engelhardt 47096f7f1b sendmail: fix query performance when filtering on checkin (Z#23244045) 2026-09-14 10:24:58 +02:00
Raphael Michel c240ebcb8e Force Django update 2026-09-14 09:51:32 +02:00
Raphael Michel ce3e59e020 PDF reports: Fix headers for overlong event names (Z#23239566) (#6521) 2026-09-14 09:38:44 +02:00
Raphael Michel 4a9da40028 [A11y] Form JavaScript: Support for <button formnovalidate> (#6536) 2026-09-14 09:38:34 +02:00
Raphael Michelandpajowu eab1efd5f4 Phone number input: Fix workaround for prefixes shared between countries (fixes #6544) (#6545)
* Phone number input: Fix workaround for prefixes shared between countries (fixes #6544)

* Update src/pretix/base/forms/questions.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-14 09:35:32 +02:00
Richard Schreiber c7290b614e Show payment-QR-codes on mobile (#6503) 2026-09-14 08:50:45 +02:00
Raphael Michel 1aea5633eb Discounts: Fix ManyToMany fields when cloning (#6547) 2026-09-11 18:35:37 +02:00
Richard Schreiber 86cc719651 Widget: serve vite by default, origin-whitelist for vue2 (#6403) 2026-09-11 08:53:42 +02:00
Mira Weller 742a69f09f Add git-blame-ignore-revs file (see #6541) 2026-09-10 12:54:51 +02:00
Yasunobu YesNo Kawaguchi c05dee7af5 Translations: Update Japanese
Currently translated at 100.0% (6419 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2026-09-10 12:36:49 +02:00
Albizuri e399a49c8b Translations: Update Basque
Currently translated at 48.0% (3086 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/eu/

powered by weblate
2026-09-10 12:36:49 +02:00
Linnea Thelander b9b951e56b Translations: Update Swedish
Currently translated at 98.0% (255 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/sv/

powered by weblate
2026-09-10 12:36:49 +02:00
Linnea Thelander 88bb1f4008 Translations: Update Swedish
Currently translated at 85.8% (5509 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/sv/

powered by weblate
2026-09-10 12:36:49 +02:00
Renne Rocha 24e7db7ddd Translations: Update Portuguese (Brazil)
Currently translated at 92.4% (5937 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/pt_BR/

powered by weblate
2026-09-10 12:36:49 +02:00
Hijiri Umemoto 96ed6fe1a6 Translations: Update Japanese
Currently translated at 100.0% (6419 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2026-09-10 12:36:49 +02:00
59 changed files with 558 additions and 1065 deletions
+2
View File
@@ -0,0 +1,2 @@
# Format pre-vue code with eslint where possible (2026-09-10)
d85e52c83ed3639e040372fd0052e842e4899d90
-12
View File
@@ -30,8 +30,6 @@ 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
@@ -110,8 +108,6 @@ 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,
@@ -172,8 +168,6 @@ 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,
@@ -220,8 +214,6 @@ 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,
@@ -257,8 +249,6 @@ 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,
@@ -323,8 +313,6 @@ 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,
+3 -3
View File
@@ -4148,9 +4148,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+5 -5
View File
@@ -37,10 +37,10 @@ dependencies = [
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"Django[argon2]==5.2.*,>=5.2.17",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==9.1.*",
"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.13.*",
"PyJWT==2.14.*",
"phonenumberslite==9.0.*",
"Pillow==12.3.*",
"pretix-plugin-build",
@@ -84,7 +84,7 @@ dependencies = [
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pypdf==6.5.*",
"pypdf==6.11.*",
"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.68.*",
"sentry-sdk==2.69.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
+1 -5
View File
@@ -19,8 +19,6 @@
# 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
@@ -40,8 +38,7 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_from', 'available_until', 'require_membership', 'require_membership_types',
'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'available_from', 'available_until', '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',
@@ -53,7 +50,6 @@ 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)
+1 -11
View File
@@ -1585,11 +1585,6 @@ 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:
@@ -1660,12 +1655,7 @@ 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, [
m for m in memberships if m.is_valid(
cp.subevent or self.context['event'],
cp.valid_from,
)
])
cp.addon_to, cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
+1 -1
View File
@@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.discounts.prefetch_related(
'limit_sales_channels', 'require_membership_types',
'limit_sales_channels',
)
@transaction.atomic()
+27 -4
View File
@@ -54,6 +54,7 @@ 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 (
@@ -197,7 +198,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
def iterate_sheet(self, form_data, sheet):
_ = gettext
if sheet == 'invoices':
yield [
headers = [
_('Invoice number'),
_('Date'),
_('Order code'),
@@ -230,8 +231,18 @@ 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,
@@ -242,7 +253,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'
@@ -280,7 +291,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
if mid:
pmis.append(mid)
pmi = '\n'.join(pmis)
yield [
line = [
i.full_invoice_no,
date_format(i.date, "SHORT_DATE_FORMAT"),
i.order.code,
@@ -315,8 +326,20 @@ 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'),
+10 -4
View File
@@ -350,16 +350,22 @@ class WrappedPhonePrefixSelect(Select):
return super().render(name, value or self.initial, *args, **kwargs)
def get_context(self, name, value, attrs):
if value and self.choices[1][0] != value:
matching_choices = len([1 for p, c in self.choices if p == value])
# 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 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 aditional choice value with
# the US. As a workaround, we detect this case and add an additional choice value with
# just <option value="+1">+1</option> without an explicit country.
self.choices.insert(1, (value, value))
self.choices = [
choices[0],
(value, value),
*choices[1:],
]
context = super().get_context(name, value, attrs)
return context
@@ -107,6 +107,9 @@ 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 = []
@@ -1,24 +0,0 @@
# 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"),
),
]
+1 -1
View File
@@ -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
+1
View File
@@ -166,6 +166,7 @@ class Device(LoggedModel):
)
security_profile = models.CharField(
max_length=190,
verbose_name=_('Security profile'),
default='full',
null=True,
blank=False
+18 -123
View File
@@ -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/>.
#
import datetime
from collections import defaultdict
from collections import defaultdict, namedtuple
from decimal import Decimal
from itertools import groupby
from math import ceil, inf
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
from typing import Dict
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -35,20 +35,10 @@ 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
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
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
'voucher_discount'])
class Discount(LoggedModel):
@@ -106,16 +96,6 @@ 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)")
@@ -259,46 +239,6 @@ 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()
@@ -310,7 +250,6 @@ 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:
@@ -404,7 +343,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 definitively potential discount
# but only 1 t-shirt) -> 1 shirt definitiv 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)
@@ -461,25 +400,19 @@ 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, p in
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
positions.items()
if (
(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)
))
(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)
)
]
@@ -489,15 +422,12 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, p in
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
positions.items()
if (
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)
))
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'))
)
]
@@ -598,38 +528,3 @@ 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
)
+1 -3
View File
@@ -1081,7 +1081,6 @@ 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 = {}
@@ -1093,10 +1092,9 @@ 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'):
+1 -3
View File
@@ -510,9 +510,7 @@ 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. 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.")
"additional donations for your event.")
)
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
-9
View File
@@ -3413,15 +3413,6 @@ 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)
+40 -34
View File
@@ -801,6 +801,18 @@ 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):
@@ -1153,11 +1165,10 @@ class Renderer:
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
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):
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):
# swap dimensions due to pdf being rotated
page_size = page_size[::-1]
canvas.setPageSize(page_size)
@@ -1232,9 +1243,7 @@ class Renderer:
for i, page in enumerate(fg_pdf.pages):
bg_page = self.bg_pdf.pages[i]
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
output.add_page(page)
_merge_with_correct_page_media_box(output, page, bg_page)
# pdf_header is a string like "%pdf-X.X"
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
@@ -1299,39 +1308,36 @@ 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]
_correct_page_media_box(bg_page)
page.merge_page(bg_page, over=False)
_merge_with_correct_page_media_box(output, page, bg_page)
# pdf_header is a string like "%pdf-X.X"
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)
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)
def _correct_page_media_box(page: pypdf.PageObject):
if page.rotation != 0:
page.transfer_rotation_to_content()
media_box = page.mediabox
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."""
trsf = pypdf.Transformation()
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]),
))
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)
@deconstructible
+16 -59
View File
@@ -47,7 +47,6 @@ 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,
@@ -58,8 +57,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, Customer, Event, InvoiceAddress, Item, ItemVariation,
SalesChannel, Seat, SeatCategoryMapping, Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CheckoutSession, OrderFee
@@ -418,15 +417,13 @@ class CartManager:
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None,
customer: Customer=None):
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=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()
@@ -1547,7 +1544,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.memberships_valid_by_time(self.memberships))
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
@@ -1562,13 +1559,6 @@ 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:
@@ -1722,8 +1712,7 @@ 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,
customer: int=None) -> None:
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -1745,16 +1734,10 @@ 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, customer=customer)
sales_channel=sales_channel)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -1764,7 +1747,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, customer=None) -> None:
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
"""
:param event: The event ID in question
:param voucher: A voucher code
@@ -1775,14 +1758,9 @@ 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, customer=customer)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.apply_voucher(voucher)
cm.commit()
except LockTimeoutException:
@@ -1792,8 +1770,7 @@ 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, customer=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) -> None:
"""
Removes an item specified by its position ID from a user's cart.
:param event: The event ID in question
@@ -1805,14 +1782,9 @@ 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, customer=customer)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
@@ -1822,7 +1794,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, customer=None) -> None:
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Removes all items from a user's cart.
:param event: The event ID in question
@@ -1833,14 +1805,9 @@ 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, customer=customer)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.clear()
cm.commit()
except LockTimeoutException:
@@ -1850,7 +1817,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, customer=None) -> dict:
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
"""
Resets the expiry time of a cart to the configured reservation time of this event.
Limited to 11x the reservation time.
@@ -1863,14 +1830,9 @@ 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, customer=customer)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.commit()
return {
"success": cm.num_extended_positions,
@@ -1886,7 +1848,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, customer=None) -> None:
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=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.
@@ -1907,14 +1869,9 @@ 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, customer=customer)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit()
+1 -11
View File
@@ -51,7 +51,6 @@ 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:
@@ -112,13 +111,6 @@ 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)
@@ -130,8 +122,7 @@ 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.memberships_valid_by_time(self.memberships))
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
@@ -186,7 +177,6 @@ class CrossSellingService:
)
if self.customer else None
),
_discount_cache=self._discount_cache,
)
new_items = list()
for item in items:
+1 -1
View File
@@ -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.
Validate that a set of cart or order positions. This currently does not validate
:param customer: Customer to validate for
:param positions: List of order or cart positions
+2 -8
View File
@@ -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, lazy
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
from django_scopes import scopes_disabled
@@ -923,18 +923,12 @@ 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.memberships_valid_by_time(memberships))
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
+5 -19
View File
@@ -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, Membership, OrderFee, OrderPosition, SalesChannel, Voucher,
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -167,15 +167,14 @@ 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, List[Membership]]],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
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, memberships_valid_by_time)``
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
``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
@@ -191,17 +190,6 @@ 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()),
@@ -210,11 +198,9 @@ 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,
_filter_memberships(memberships_valid_by_time))
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount,
memberships_valid_by_time)
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
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
+52 -23
View File
@@ -20,6 +20,7 @@
# <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
@@ -35,32 +36,32 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Decimal, arg='', hide_currency=False):
if isinstance(value, (float, int)):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
if isinstance(value, (float, int, str)):
if value == '':
return value
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()
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 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)
if hide_currency:
return floatformat(value, f"{places}g")
return floatformat(value, f"{render_places}g")
try:
locale = Locale(get_babel_locale())
@@ -68,14 +69,29 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
locale = "en"
try:
return format_currency(value, arg, locale=locale)
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,
)
except:
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
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)
@register.filter("money_numberfield")
def money_numberfield_filter(value: Decimal, arg=''):
if isinstance(value, (float, int)):
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
if isinstance(value, (float, int, str)):
value = Decimal(value)
if not isinstance(value, Decimal):
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
@@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''):
@register.filter(is_safe=True)
def tax_rate_format(number):
def tax_rate_format(number: Optional[Decimal | float | int | str]):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
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()
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
number,
-number.normalize().as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
-15
View File
@@ -45,8 +45,6 @@ class DiscountForm(I18nModelForm):
'limit_sales_channels',
'available_from',
'available_until',
'require_membership',
'require_membership_types',
'subevent_date_from',
'subevent_date_until',
'subevent_mode',
@@ -71,7 +69,6 @@ class DiscountForm(I18nModelForm):
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -91,9 +88,6 @@ 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):
@@ -113,13 +107,6 @@ 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']
@@ -136,6 +123,4 @@ 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
+3 -3
View File
@@ -105,12 +105,12 @@ class GlobalSettingsForm(SettingsForm):
domain=settings.SITE_URL
)
)),
('widget_vite_origins', forms.CharField(
('widget_vue2_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="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.",
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.",
))
])
responses = register_global_settings.send(self)
+1
View File
@@ -774,6 +774,7 @@ 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,12 +17,6 @@
{% 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,23 +9,7 @@
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% 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>
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "User" %}{% endblock %}
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
@@ -16,6 +17,10 @@
{% 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 %}
@@ -59,8 +64,72 @@
{% bootstrap_field form.is_verified layout='control' %}
{% endif %}
{% bootstrap_field form.last_login layout='control' %}
{% bootstrap_field form.require_2fa 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>
</fieldset>
<fieldset>
<legend>{% trans "Team memberships" %}</legend>
+1
View File
@@ -78,6 +78,7 @@ 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'),
+3
View File
@@ -163,6 +163,9 @@ 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)
+38 -2
View File
@@ -41,15 +41,18 @@ 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 User
from pretix.base.models import U2FDevice, User, WebAuthnDevice
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 RecentAuthenticationRequiredMixin
from pretix.control.views.user import (
REAL_DEVICE_TYPES, RecentAuthenticationRequiredMixin,
)
def get_used_backend(request):
@@ -107,6 +110,21 @@ 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):
@@ -183,6 +201,24 @@ 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"
+4 -2
View File
@@ -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')
_('Comment'), _('Budget'), _('Budget used')
]
writer.writerow(headers)
@@ -170,7 +170,9 @@ 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.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 "",
]
writer.writerow(row)
+5 -5
View File
@@ -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-08-25 00:00+0000\n"
"PO-Revision-Date: 2026-09-14 23: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.8.1\n"
"X-Generator: Weblate 2026.9.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"
+7 -7
View File
@@ -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-08-24 14:30+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+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.8.1\n"
"X-Generator: Weblate 2026.9\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 ""
msgstr "Eskaeraren xehetasunak"
#: 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 ""
msgstr "Eskaeraren xehetasunak aldatu dira."
#: 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 ""
msgstr "Eskaeraren xehetasunak: %(code)s"
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/control/templates/pretixcontrol/orders/index.html
+51 -70
View File
@@ -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-08-16 22:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\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.8.1\n"
"X-Generator: Weblate 2026.9\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,10 +428,8 @@ 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
@@ -5691,7 +5689,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 ""
@@ -7340,12 +7338,9 @@ 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."
@@ -8105,16 +8100,12 @@ 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"
@@ -8143,22 +8134,22 @@ msgstr "未設定"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"before\" for \"{}\""
msgstr ""
msgstr "相対的な日付は、「{}」に対して「before」で表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative date cannot be expressed as \"after\" for \"{}\""
msgstr ""
msgstr "相対的な日付は「{}」に対して「after」として表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"before\" for \"{}\""
msgstr ""
msgstr "相対時間は「{}」に対して「before」で表すことはできません"
#: pretix/base/reldate.py
#, python-brace-format
msgid "A relative time cannot be expressed as \"after\" for \"{}\""
msgstr ""
msgstr "相対時間は「{}」に対して「after」で表すことはできません"
#: pretix/base/secrets.py
msgid "Random (default, works with all pretix apps)"
@@ -10067,12 +10058,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"
@@ -10083,6 +10074,10 @@ 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"
@@ -13902,26 +13897,20 @@ 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"
@@ -22533,28 +22522,24 @@ 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"
@@ -22573,28 +22558,24 @@ 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 ""
msgstr "注文ごとの質問は現在サポートされておらず、pretixPOSでは表示されません。"
#: pretix/control/templates/pretixcontrol/items/questions.html
#, fuzzy
#| msgid "The question has been reordered."
msgid "These questions are asked once per order."
msgstr "質問の並び順が変更されました。"
msgstr "これらの質問は、注文ごとに1回ずつ尋ねられます。"
#: pretix/control/templates/pretixcontrol/items/quota.html
#: pretix/control/templates/pretixcontrol/items/quota_edit.html
@@ -23186,10 +23167,8 @@ 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"
@@ -24856,14 +24835,13 @@ msgid "Hardware model"
msgstr "ハードウェアの機種"
#: pretix/control/templates/pretixcontrol/organizers/devices.html
#, fuzzy, python-format
#| msgid "Begin: %(time)s"
#, python-format
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"
@@ -28220,19 +28198,14 @@ 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."
@@ -30906,7 +30879,7 @@ msgstr ""
#: pretix/plugins/paypal2/payment.py
msgid "Allow further payments during compliance hold"
msgstr ""
msgstr "コンプライアンス保留の間、追加の支払いを許可する"
#: pretix/plugins/paypal2/payment.py
msgid ""
@@ -30916,16 +30889,20 @@ 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 --"
@@ -31190,6 +31167,10 @@ 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 ""
+11 -6
View File
@@ -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-03-30 21:00+0000\n"
"PO-Revision-Date: 2026-09-02 08:35+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 5.16.2\n"
"X-Generator: Weblate 2026.8.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "Hebraico"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "Húngaro"
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -153,7 +153,7 @@ msgstr "Espanhol (América latina)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Tailandês"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -3022,11 +3022,12 @@ 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 ""
msgstr "O campo \"%(label)s\" não pode conter uma URL (%(url)s)."
#: pretix/base/forms/questions.py
msgctxt "phonenumber"
@@ -8173,7 +8174,7 @@ msgstr "Permitida"
#: pretix/base/permissions.py
msgctxt "permission_level"
msgid "Access existing events"
msgstr ""
msgstr "Acessar eventos existentes"
#: pretix/base/permissions.py
msgctxt "permission_level"
@@ -13607,11 +13608,15 @@ 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
+5 -4
View File
@@ -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-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\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.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/_base_settings.py
msgid "English"
@@ -19170,8 +19170,9 @@ 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ätta"
msgstr "Fortsätt"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Authorize an application"
+5 -4
View File
@@ -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-08-09 06:00+0000\n"
"Last-Translator: Julien <julien@circusiloveyou.com>\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\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.8.1\n"
"X-Generator: Weblate 2026.9\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -133,8 +133,9 @@ 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ätta"
msgstr "Fortsätt"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js
+1 -9
View File
@@ -55,7 +55,6 @@ 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
@@ -238,15 +237,8 @@ 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_page(page)
nup_page.merge_transformed_page(page, Transformation().translate(tx, ty))
return nup_page
+15 -3
View File
@@ -55,6 +55,8 @@ 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
@@ -226,10 +228,20 @@ class ReportlabExportMixin:
def page_header(self, canvas, doc):
from reportlab.lib.units import mm
canvas.setFont('OpenSans', 10)
canvas.drawString(doc.leftMargin, self.pagesize[1] - 15 * mm, self.get_left_header_string())
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.drawRightString(self.pagesize[0] - doc.rightMargin, self.pagesize[1] - 15 * mm,
self.get_right_header_string())
right_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)
+2 -2
View File
@@ -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.filter(
opq = OrderPosition.objects.with_scopes_disabled().filter(
Q(item_id__in=[i.pk for i in form.cleaned_data.get('items')]) | Q(Exists(
OrderPosition.objects.filter(
OrderPosition.objects.with_scopes_disabled().filter(
addon_to_id=OuterRef('pk'),
item_id__in=[i.pk for i in form.cleaned_data.get('items')]
)
+19 -47
View File
@@ -248,19 +248,7 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
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):
class CustomerStep(CartMixin, TemplateFlowStep):
priority = 45
identifier = "customer"
template_name = "pretixpresale/event/checkout_customer.html"
@@ -338,34 +326,28 @@ class CustomerStep(CartMixin, RecomputeMixin, 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 self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
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 self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
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 self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
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 self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
else:
return self.render()
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
@@ -374,13 +356,13 @@ class CustomerStep(CartMixin, RecomputeMixin, TemplateFlowStep):
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = customer.pk
self.cart_session['customer_cart_tied_to_login'] = False
return self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
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 self._proceed_to_next()
return redirect_to_url(self.get_next_url(request))
else:
return self.render()
@@ -788,18 +770,12 @@ 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),
customer=cart_customer.pk if cart_customer else None)
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateFlowStep):
class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 50
identifier = "questions"
template_name = "pretixpresale/event/checkout_questions.html"
@@ -1010,7 +986,6 @@ class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateF
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:
@@ -1055,8 +1030,13 @@ class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateF
self.cart_session['saved_invoice_address'] = saved.pk
try:
diff = self.recompute_cart()
recomputed = True
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()
except TaxRule.SaleNotAllowed:
messages.error(request,
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
@@ -1065,23 +1045,15 @@ class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateF
self.cart_session['invoice_address'] = addr.pk
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.'))
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.'))
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)
+32 -131
View File
@@ -21,8 +21,7 @@
#
import sys
from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Union
from typing import Optional
from django.conf import settings
from django.db.models import (
@@ -30,13 +29,11 @@ from django.db.models import (
)
from django.db.models.lookups import Exact
from pretix.base.decimal import round_decimal
from pretix.base.models import (
Discount, Event, Item, ItemVariation, Membership, Quota, SalesChannel,
SeatCategoryMapping, SubEvent, Voucher,
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now
@@ -57,44 +54,10 @@ 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, _discount_cache=None):
ignore_hide_sold_out_for_item_ids=None):
base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items
@@ -227,24 +190,6 @@ 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 {}
@@ -327,11 +272,6 @@ 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):
@@ -359,51 +299,30 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order
)
configured_price = item_price_override.get(item.pk, item.default_price)
original_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = configured_price
price = original_price
include_bundled = True
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)
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 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
)
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
item.original_price = None
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 not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
@@ -433,53 +352,35 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order
)
configured_price = var_price_override.get(var.pk, var.price)
original_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = configured_price
price = original_price
include_bundled = True
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)
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 = var.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
var.suggested_price = item.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 = var.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
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
)
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
else:
var.original_price = None
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.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 hidden-xs text-center js-only blank-after">
<div class="tabcontainer col-md-6 col-sm-6 col-xs-12 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 }}"
+6 -18
View File
@@ -58,8 +58,7 @@ from django.views.generic import TemplateView, View
from django_scopes import scopes_disabled
from pretix.base.models import (
CartPosition, Customer, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent,
Voucher,
CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
)
from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
@@ -154,17 +153,6 @@ 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:
@@ -517,7 +505,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), self.cart_customer.pk if self.cart_customer else None)
time_machine_now(default=None))
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -544,7 +532,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), self.cart_customer.pk if self.cart_customer else None)
time_machine_now(default=None))
except ValueError:
return redirect_to_url(self.get_error_url())
else:
@@ -567,7 +555,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), self.cart_customer.pk if self.cart_customer else None)
request.sales_channel.identifier, time_machine_now(default=None))
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -603,7 +591,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), self.cart_customer.pk if self.cart_customer else None)
request.sales_channel.identifier, time_machine_now(default=None))
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -660,7 +648,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), self.cart_customer.pk if self.cart_customer else None)
time_machine_now(default=None))
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
+9 -8
View File
@@ -122,19 +122,20 @@ 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_VITE', False) or "beta" in request.GET:
return True
if getattr(settings, 'PRETIX_WIDGET_VUE', False) or "legacy" in request.GET:
return False
origin = request.META.get('HTTP_ORIGIN', '')
gs = GlobalSettingsObject()
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
if vite_origins and not origin:
vue_origins = gs.settings.get('widget_vue2_origins', as_type=str, default='')
if vue_origins and not origin:
referer = request.META.get('HTTP_REFERER', '')
origin = '/'.join(referer.split('/', 3)[:3])
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
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
def widget_js_etag(request, version, lang, **kwargs):
+1 -1
View File
@@ -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_VITE = os.environ.get('PRETIX_WIDGET_VITE', '') not in ('', '0')
PRETIX_WIDGET_VUE = os.environ.get('PRETIX_WIDGET_VUE', '') 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]', '@').replace('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replace('[dot]', '.')
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
})
})
@@ -233,6 +233,7 @@ 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,10 +514,6 @@ 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
-2
View File
@@ -46,8 +46,6 @@ 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,
+33 -33
View File
@@ -749,22 +749,22 @@ def test_query_count_many_items(event, itemcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=10,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
@@ -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=10,
expect_num_queries=9,
)
@@ -803,22 +803,22 @@ def test_query_count_many_categories_and_discounts(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=10,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
@@ -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=10,
expect_num_queries=9,
)
@@ -857,7 +857,7 @@ def test_query_count_many_cartpos(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=9,
expect_num_queries=8,
)
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=10,
expect_num_queries=9,
)
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=14,
expect_num_queries=13,
)
+33 -6
View File
@@ -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
from pretix.base.templatetags.money import money_filter, tax_rate_format
TEMPLATE_REPLACE_PAGE = Template(
"{% load urlreplace %}{% url_replace request 'page' 3 %}"
@@ -60,7 +60,9 @@ 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 + ""),
@@ -70,11 +72,14 @@ def test_urlreplace_replace_parameter():
# unknown currency
("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"),
("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"),
("de", Decimal("1234.567"), "FOO", "1.234,567" + NBSP + "FOO"),
# rounding errors
("de", Decimal("1.234"), "EUR", "1,23" + NBSP + ""),
("de", Decimal("1023.1"), "JPY", "JPY 1.023,10"),
# 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 + "¥"),
]
)
def test_money_filter(locale, amount, currency, expected):
@@ -98,9 +103,31 @@ 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,10"),
("de", Decimal("1023.1"), "JPY", "1.023,1"),
]
)
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
+2 -2
View File
@@ -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"' \
'"Maximum usages","Seat","Comment","Budget","Budget used"' \
'\r\n"ABCDEFG","","Early-bird ticket","No","No","No effect","","","0",' \
'"1","",""'
'"1","","","",""'
def test_filter_status_valid(self):
with scopes_disabled():
+3 -7
View File
@@ -89,11 +89,11 @@ def _widget_assets():
f'Start it with: npm run dev:widget'
)
yield
elif os.environ.get("PRETIX_WIDGET_VITE"):
elif os.environ.get("PRETIX_WIDGET_VUE"):
yield # Old widget, no build needed
else:
subprocess.check_call(['npm', 'run', 'build:widget'], cwd=PROJECT_ROOT)
yield
else:
yield # Old widget, no build needed
# ============================================================================
@@ -155,10 +155,6 @@ 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
-283
View File
@@ -1,283 +0,0 @@
#
# 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")