Compare commits

...
Author SHA1 Message Date
Raphael Michel 146f63e1f2 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?
2026-08-05 11:38:17 +02:00
Raphael MichelandGitHub 8133061fe1 Devices: Store timestamp of last contact (#6453) 2026-08-05 10:57:13 +02:00
Raphael Michel 288ac50600 Merge branch 'html-injection-placeholder' into 'master'
Prevent HTML injection in email preview (Z#23241741)

See merge request pretix/pretix!47
2026-08-05 10:56:46 +02:00
Raphael Michel e7657a3dd3 Prevent HTML injection in email preview (Z#23241741) 2026-08-05 10:56:46 +02:00
Martin Gross b659534772 isort 2026-08-04 23:01:29 +02:00
Martin Gross c11ebb1391 Skip over Exporters with ExportError-Exceptions (such as ORMQL-Exporter) (Fixes PRETIXEU-F3X) 2026-08-04 22:59:19 +02:00
pajowuandGitHub b20557a996 Show correct number of currency placed in widget (Z#23241522) (#6425) 2026-08-04 11:19:43 +02:00
Martin Gross e4a0bba3bc Fix font enumeration 2026-08-04 10:22:09 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c369ba5f60 Update cryptography requirement from >=49.0.0 to >=50.0.0 (#6443)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/49.0.0...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.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-08-04 08:47:14 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2c876057bf Bump markdown from 3.10.2 to 3.10.3 (#6442)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.2 to 3.10.3.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.2...3.10.3)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.10.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:46:52 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b70c1b02c2 Bump django-bootstrap3 from 26.1 to 26.2 (#6445)
Bumps [django-bootstrap3](https://github.com/zostera/django-bootstrap3) from 26.1 to 26.2.
- [Changelog](https://github.com/zostera/django-bootstrap3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/zostera/django-bootstrap3/compare/v26.1...v26.2)

---
updated-dependencies:
- dependency-name: django-bootstrap3
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:46:25 +02:00
pajowuandGitHub 01d736361d Cart Manager: Round custom price to currency when adding (#6426) 2026-08-03 14:26:57 +02:00
Raphael Michel 7627e4b548 Add Hungarian language 2026-07-31 16:56:43 +02:00
22 changed files with 323 additions and 65 deletions
+3 -3
View File
@@ -33,12 +33,12 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=49.0.0",
"cryptography>=50.0.0",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-filter==26.1",
@@ -67,7 +67,7 @@ dependencies = [
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
+1
View File
@@ -104,6 +104,7 @@ ALL_LANGUAGES = [
('gl', _('Galician')),
('el', _('Greek')),
('he', _('Hebrew')),
('hu', _('Hungarian')),
('id', _('Indonesian')),
('it', _('Italian')),
('ja', _('Japanese')),
+25 -1
View File
@@ -20,8 +20,11 @@
# <https://www.gnu.org/licenses/>.
#
import logging
from datetime import timedelta
from django.contrib.auth.models import AnonymousUser
from django.db import DatabaseError
from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -30,6 +33,7 @@ from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles,
)
from pretix.base.models import Device
from pretix.base.models.devices import DeviceLastSeen
logger = logging.getLogger(__name__)
@@ -42,7 +46,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
model = self.get_model()
try:
with scopes_disabled():
device = model.objects.select_related('organizer').get(api_token=key)
device = model.objects.select_related('organizer', 'last_seen').get(api_token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
@@ -53,6 +57,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.')
self._update_last_seen(device)
return AnonymousUser(), device
def authenticate(self, request):
@@ -63,3 +68,22 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.')
return r
def _update_last_seen(self, device: Device):
try:
try:
last_seen_obj = device.last_seen
except DeviceLastSeen.DoesNotExist:
# First request from device, create model, ignore result. Use get_or_create to be safe
# against concurrent create requests
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
else:
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
# We don't need to know the last seen info of a device to more precision than this,
# so we can avoid some database writes if the device is bursting a lot of requests.
return
last_seen_obj.last_seen = now()
last_seen_obj.save(update_fields=["last_seen"])
except DatabaseError:
# Do not stop the request from happening
logger.exception("Database error while updating last_seen")
+12 -5
View File
@@ -45,7 +45,8 @@ from pretix.base.models import (
)
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.export import (
export, init_event_exporters, init_organizer_exporters, multiexport,
ExportError, export, init_event_exporters, init_organizer_exporters,
multiexport,
)
from pretix.helpers.http import ChunkBasedFileResponse
@@ -149,8 +150,11 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
))
exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
try:
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
except ExportError:
pass
return exporters
def do_export(self, cf, instance, data):
@@ -180,8 +184,11 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
))
exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
try:
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
except ExportError:
pass
return exporters
def do_export(self, cf, instance, data):
@@ -0,0 +1,43 @@
# Generated by Django 5.2.16 on 2026-08-05 08:00
import django.db.models.deletion
from django.db import migrations, models
import pretix.helpers.database
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0306_alter_eventmetaproperty_unique_together"),
]
operations = [
migrations.CreateModel(
name="DeviceLastSeen",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("last_seen", models.DateTimeField(auto_now=True)),
(
"device",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.device",
),
),
],
),
migrations.AddIndex(
model_name="devicelastseen",
index=pretix.helpers.database.BrinIndexIgnoredOnSQLite(
models.F("last_seen"),
autosummarize=True,
name="pretixbase_device_last_seen",
),
),
]
+20
View File
@@ -32,6 +32,7 @@ from pretix.base.models import LoggedModel
from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission,
)
from pretix.helpers import BrinIndexIgnoredOnSQLite
@scopes_disabled()
@@ -287,3 +288,22 @@ class Device(LoggedModel):
return self.get_events_with_any_permission()
else:
return self.organizer.events.none()
class DeviceLastSeen(models.Model):
# This is a separate model since we expect it to get A LOT of writes and PostgreSQL always
# writes full rows and then needs to update all indexes on the row, so this is going to save a
# lot of write traffic on the databse
device = models.OneToOneField("Device", on_delete=models.CASCADE, related_name="last_seen")
last_seen = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
BrinIndexIgnoredOnSQLite(
# BRIN indexes are highly efficient on lots of updates, especially of chronological data
# and especially if we later want to query them by range, as we likely want to.
"last_seen",
name="pretixbase_device_last_seen",
autosummarize=True
)
]
+5
View File
@@ -53,6 +53,7 @@ from django.utils.translation import (
)
from django_scopes import scopes_disabled
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 (
@@ -916,6 +917,8 @@ class CartManager:
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
op = self.AddOperation(
count=i['count'],
item=item,
@@ -1038,6 +1041,8 @@ class CartManager:
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
if ca.listed_price != listed_price:
+11 -3
View File
@@ -801,11 +801,10 @@ def get_available_placeholders(event, base_parameters, rich=False):
return params
def get_sample_context(event, context_parameters, rich=True):
def prepare_sample_context_for_preview(placeholder_to_sample):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
for k, sample in placeholder_to_sample.items():
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
@@ -830,3 +829,12 @@ def get_sample_context(event, context_parameters, rich=True):
escape(sample)
))
return context_dict
def get_sample_context(event, context_parameters, rich=True):
return prepare_sample_context_for_preview(
{
k: v.render_sample(event)
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items()
}
)
+31 -16
View File
@@ -48,19 +48,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
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"))
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,9 +61,24 @@ 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 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: Decimal, arg=''):
return money_filter(value, arg, hide_currency=True)
@register.filter("money_numberfield")
@@ -91,11 +99,18 @@ def tax_rate_format(number):
"""
Display a Decimal to its significant decimal places, used for tax rates.
"""
assert isinstance(number, Decimal)
if isinstance(number, (float, int, str)):
number = Decimal(number)
if number is None:
number = Decimal('0.00')
if not isinstance(number, Decimal):
if number == '':
return number
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
return mark_safe(
formats.number_format(
number.normalize(),
-number.as_tuple().exponent,
number,
-number.normalize().as_tuple().exponent,
use_l10n=True,
force_grouping=False,
)
+8 -10
View File
@@ -108,6 +108,9 @@ from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export,
)
from pretix.base.services.mail import mail, prefix_subject
from pretix.base.services.placeholders import (
prepare_sample_context_for_preview,
)
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm
@@ -345,16 +348,11 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
for p, s in MailSettingsForm(obj=self.request.organizer)._get_sample_context(
MailSettingsForm.base_context[item]).items():
if s.strip().startswith('*'):
ctx[p] = s
else:
ctx[p] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
s
)
ctx = prepare_sample_context_for_preview(
MailSettingsForm(obj=self.request.organizer)._get_sample_context(
MailSettingsForm.base_context[item]
)
)
return self.SafeDict(ctx)
def post(self, request, *args, **kwargs):
+19
View File
@@ -22,6 +22,7 @@
import contextlib
from django.conf import settings
from django.contrib.postgres.indexes import BrinIndex
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction
from django.db.models import (
@@ -285,3 +286,21 @@ def get_deterministic_ordering(model, ordering):
# on the primary key to provide total ordering.
ordering.append("-pk")
return ordering
class IgnoreOnSQLiteMixin:
# Mixin to allow defining PostgreSQL-specific indexes that will just not be created
# on SQLite. SQLite is supported for testing only anyways!
def create_sql(self, model, schema_editor, *args, **kwargs):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
return ""
return super().create_sql(model, schema_editor, *args, **kwargs)
def remove_sql(self, model, schema_editor, **kwargs):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
return ""
return super().remove_sql(model, schema_editor, **kwargs)
class BrinIndexIgnoredOnSQLite(IgnoreOnSQLiteMixin, BrinIndex):
pass
+1 -1
View File
@@ -147,7 +147,7 @@ def get_font_stylesheet(font_name, organizer: Organizer = None, event: Event = N
stylesheet = []
font = get_fonts(event)[font_name]
for sty, formats in font.items():
if sty == 'sample':
if sty in ['sample', 'pdf_only']:
continue
stylesheet.append('@font-face { ')
stylesheet.append('font-family: "{}";'.format(font_name))
+1
View File
@@ -795,6 +795,7 @@ class WidgetAPIProductList(EventListMixin, View):
'target_url': eventreverse_absolute(request.event, 'presale:event.index'),
'subevent': self.subevent.pk if self.subevent else None,
'currency': request.event.currency,
'currency_places': settings.CURRENCY_PLACES.get(request.event.currency, 2),
'display_net_prices': request.event.settings.display_net_prices,
'use_native_spinners': request.event.settings.widget_use_native_spinners,
'show_variations_expanded': request.event.settings.show_variations_expanded,
@@ -380,16 +380,16 @@ Vue.component('pricebox', {
},
display_price: function () {
if (this.$root.display_net_prices) {
return floatformat(parseFloat(this.price.net), 2);
return floatformat(this.price.net, this.$root.currency_places);
} else {
return floatformat(parseFloat(this.price.gross), 2);
return floatformat(this.price.gross, this.$root.currency_places);
}
},
display_price_nonlocalized: function () {
if (this.$root.display_net_prices) {
return parseFloat(this.price.net).toFixed(2);
return parseFloat(this.price.net).toFixed(this.$root.currency_places);
} else {
return parseFloat(this.price.gross).toFixed(2);
return parseFloat(this.price.gross).toFixed(this.$root.currency_places);
}
},
suggested_price_nonlocalized: function () {
@@ -398,9 +398,9 @@ Vue.component('pricebox', {
price = this.price;
}
if (this.$root.display_net_prices) {
return parseFloat(price.net).toFixed(2);
return parseFloat(price.net).toFixed(this.$root.currency_places);
} else {
return parseFloat(price.gross).toFixed(2);
return parseFloat(price.gross).toFixed(this.$root.currency_places);
}
},
original_price_aria_label: function () {
@@ -410,7 +410,7 @@ Vue.component('pricebox', {
return django.interpolate(strings.new_price, [this.stripHTML(this.priceline)]);
},
original_line: function () {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(parseFloat(this.original_price), 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.original_price, this.$root.currency_places);
},
priceline: function () {
if (this.price.gross === "0.00") {
@@ -645,19 +645,19 @@ Vue.component('item', {
if (this.item.free_price) {
return django.interpolate(strings.price_from, {
'currency': this.$root.currency,
'price': floatformat(this.item.min_price, 2)
'price': floatformat(this.item.min_price, this.$root.currency_places)
}, true).replace(this.$root.currency, '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + '</span>');
} else if (this.item.min_price !== this.item.max_price) {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> "
+ floatformat(this.item.min_price, 2) + " "
+ floatformat(this.item.max_price, 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> "
+ floatformat(this.item.min_price, this.$root.currency_places) + " "
+ floatformat(this.item.max_price, this.$root.currency_places);
} else if (this.item.min_price === "0.00" && this.item.max_price === "0.00") {
if (this.item.mandatory_priced_addons) {
return "\xA0"; // nbsp, because an empty string would cause the HTML element to collapse
}
return strings.free;
} else {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.item.min_price, 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.item.min_price, this.$root.currency_places);
}
},
variationsToggleLabel: function () {
@@ -1949,6 +1949,7 @@ var shared_root_methods = {
root.location = data.location;
root.categories = data.items_by_category;
root.currency = data.currency;
root.currency_places = data.currency_places;
root.display_net_prices = data.display_net_prices;
root.voucher_explanation_text = data.voucher_explanation_text;
root.error = data.error;
@@ -1983,6 +1984,7 @@ var shared_root_methods = {
}, function (error) {
root.categories = [];
root.currency = '';
root.currency_places = 2;
if (error.status === 429) {
root.error = strings['loading_error_429'];
root.connection_error = true;
@@ -2339,6 +2341,7 @@ var create_widget = function (element, html_id=null) {
is_button: false,
categories: null,
currency: null,
currency_places: 2,
name: null,
date_range: null,
location: null,
@@ -22,6 +22,7 @@ export interface ProductListResponse {
location?: string
items_by_category?: Category[]
currency?: string
currency_places?: number
display_net_prices?: boolean
voucher_explanation_text?: string
error?: string
@@ -72,7 +72,7 @@ const pricerange = computed(() => {
STRINGS.price_from,
{
currency: store.currency,
price: floatformat(props.item.min_price || '0', 2),
price: floatformat(props.item.min_price || '0', store.currency_places),
},
true
).replace(
@@ -80,14 +80,14 @@ const pricerange = computed(() => {
`<span class="pretix-widget-pricebox-currency">${store.currency}</span>`
)
} else if (props.item.min_price !== props.item.max_price) {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)} ${floatformat(props.item.max_price || '0', 2)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', store.currency_places)} ${floatformat(props.item.max_price || '0', store.currency_places)}`
} else if (props.item.min_price === '0.00' && props.item.max_price === '0.00') {
if (props.item.mandatory_priced_addons) {
return '\xA0' // nbsp, because an empty string would cause the HTML element to collapse
}
return STRINGS.free
} else {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', store.currency_places)}`
}
})
@@ -25,9 +25,9 @@ const ariaLabelledby = computed(() => `${store.htmlId}-item-label-${props.itemId
const displayPrice = computed(() => {
if (store.displayNetPrices) {
return floatformat(parseFloat(props.price.net), 2)
return floatformat(props.price.net, store.currency_places)
}
return floatformat(parseFloat(props.price.gross), 2)
return floatformat(props.price.gross, store.currency_places)
})
const displayPriceNonlocalized = computed(() => {
@@ -40,15 +40,15 @@ const displayPriceNonlocalized = computed(() => {
const suggestedPriceNonlocalized = computed(() => {
const price = props.suggestedPrice ?? props.price
if (store.displayNetPrices) {
return parseFloat(price.net).toFixed(2)
return parseFloat(price.net).toFixed(store.currency_places)
}
return parseFloat(price.gross).toFixed(2)
return parseFloat(price.gross).toFixed(store.currency_places)
})
// TODO BAD
const originalLine = computed(() => {
if (!props.originalPrice) return ''
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(parseFloat(props.originalPrice), 2)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.originalPrice, store.currency_places)}`
})
// TODO BAD
@@ -71,6 +71,7 @@ export function createWidgetStore (config: {
frontpageText: null as string | null,
categories: [] as Category[],
currency: '',
currency_places: 2,
displayNetPrices: false,
voucherExplanationText: null as string | null,
displayAddToCart: false,
@@ -299,6 +300,7 @@ export function createWidgetStore (config: {
this.location = data.location ?? null
this.categories = data.items_by_category ?? []
this.currency = data.currency ?? ''
this.currency_places = data.currency_places ?? 2
this.displayNetPrices = data.display_net_prices ?? false
this.voucherExplanationText = data.voucher_explanation_text ?? null
this.error = data.error ?? null
@@ -338,6 +340,7 @@ export function createWidgetStore (config: {
} catch (e) {
this.categories = []
this.currency = ''
this.currency_places = 2
if (e instanceof ApiError && e.status === 429) {
this.error = STRINGS.loading_error_429
} else {
+26
View File
@@ -20,13 +20,16 @@
# <https://www.gnu.org/licenses/>.
#
import base64
from datetime import datetime, timezone
import pytest
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django_scopes import scopes_disabled
from freezegun import freeze_time
from pretix.base.models import Device
from pretix.base.models.devices import DeviceLastSeen
@pytest.fixture
@@ -386,3 +389,26 @@ def test_device_info_key_sets(device_client, device: Device):
base64.b64decode(ks['diversification_key']),
padding.PKCS1v15()
)
@pytest.mark.django_db
def test_update_last_seen(device_client, device: Device):
assert not DeviceLastSeen.objects.exists()
with freeze_time("2020-01-10T14:30:00+00:00"):
resp = device_client.get('/api/v1/device/info')
assert resp.status_code == 200
assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, tzinfo=timezone.utc)
with freeze_time("2020-01-10T14:30:05+00:00"):
resp = device_client.get('/api/v1/device/info')
assert resp.status_code == 200
# No update, interal too short
device.last_seen.refresh_from_db()
assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, tzinfo=timezone.utc)
with freeze_time("2020-01-10T14:30:30+00:00"):
resp = device_client.get('/api/v1/device/info')
assert resp.status_code == 200
device.last_seen.refresh_from_db()
assert device.last_seen.last_seen == datetime(2020, 1, 10, 14, 30, 30, tzinfo=timezone.utc)
+25 -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 %}"
@@ -70,11 +70,13 @@ 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", 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 +100,26 @@ 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"),
]
)
def test_tax_rate_format(locale, rate, expected):
translation.activate(locale)
assert tax_rate_format(rate) == expected
+61
View File
@@ -697,6 +697,42 @@ class CartTest(CartTestMixin, TestCase):
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, 23)
def test_free_price_rounding(self):
self.ticket.free_price = True
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '40.1234',
}, follow=True)
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
cr1 = CartPosition.objects.get()
assert cr1.listed_price == Decimal('23.00')
assert cr1.custom_price_input == Decimal('40.12')
assert cr1.price == Decimal('40.12')
def test_free_price_rounding_jpy(self):
self.event.currency = "JPY"
self.event.save()
self.ticket.free_price = True
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '40.1234',
}, follow=True)
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
cr1 = CartPosition.objects.get()
assert cr1.listed_price == Decimal('23.00')
assert cr1.custom_price_input == Decimal('40.00')
assert cr1.price == Decimal('40.00')
def test_variation_inactive(self):
self.shirt_red.active = False
self.shirt_red.save()
@@ -3060,6 +3096,31 @@ class CartAddonTest(CartTestMixin, TestCase):
assert cp1.addons.count() == 3
assert all(a.price == Decimal('12.00') for a in cp1.addons.all())
@classscope(attr='orga')
def test_free_price_rounding(self):
self.event.settings.locales = ['de']
self.event.settings.locale = 'de'
self.event.currency = "JPY"
self.event.save()
self.workshop1.free_price = True
self.workshop1.save()
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() - timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), {
'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk): '1',
'cp_{}_item_{}_price'.format(cp1.pk, self.workshop1.pk): '99,99',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
assert cp1.addons.count() == 1
assert cp1.addons.first().item == self.workshop1
assert cp1.addons.first().price == Decimal('100')
@classscope(attr='orga')
def test_change_number(self):
cp1 = CartPosition.objects.create(
+4
View File
@@ -173,6 +173,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"frontpage_text": "",
"location": "",
"currency": "EUR",
"currency_places": 2,
"show_variations_expanded": False,
"display_net_prices": False,
"use_native_spinners": False,
@@ -379,6 +380,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"frontpage_text": "",
"location": "",
"currency": "EUR",
"currency_places": 2,
"show_variations_expanded": False,
"display_net_prices": False,
"use_native_spinners": False,
@@ -439,6 +441,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"frontpage_text": "",
"location": "",
"currency": "EUR",
"currency_places": 2,
"show_variations_expanded": False,
"display_net_prices": False,
"use_native_spinners": False,
@@ -524,6 +527,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"frontpage_text": "",
"location": "",
"currency": "EUR",
"currency_places": 2,
'poweredby': '<a href="https://pretix.eu" target="_blank" rel="noopener">ticketing powered by pretix</a>',
"show_variations_expanded": False,
"display_net_prices": False,