mirror of
https://github.com/pretix/pretix.git
synced 2026-08-16 11:46:27 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146f63e1f2 | ||
|
|
8133061fe1 | ||
|
|
288ac50600 | ||
|
|
e7657a3dd3 | ||
|
|
b659534772 | ||
|
|
c11ebb1391 | ||
|
|
b20557a996 | ||
|
|
e4a0bba3bc | ||
|
|
c369ba5f60 | ||
|
|
2c876057bf | ||
|
|
b70c1b02c2 | ||
|
|
01d736361d | ||
|
|
7627e4b548 |
+3
-3
@@ -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.*",
|
||||
|
||||
@@ -104,6 +104,7 @@ ALL_LANGUAGES = [
|
||||
('gl', _('Galician')),
|
||||
('el', _('Greek')),
|
||||
('he', _('Hebrew')),
|
||||
('hu', _('Hungarian')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user