Compare commits

...
Author SHA1 Message Date
Raphael Michel bc52dc81f3 New implementation 2026-04-01 10:38:48 +02:00
Raphael Michel f3dbbce39c Order change form: Allow to add multiple identical positions (Z#23227479) 2026-03-30 17:36:36 +02:00
Raphael Michel a2cef22ea8 Bump version to 2026.4.0.dev0 2026-03-30 15:01:39 +02:00
Raphael Michel 3843448812 Bump version to 2026.3.0 2026-03-30 15:01:30 +02:00
Kara Engelhardt 49893ca9df Fix crash in mail_send_task for nonexistant mails 2026-03-30 14:57:56 +02:00
Raphael Michel 4eade5070e Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-03-30 14:01:13 +02:00
Raphael Michel 32b1997208 Translations: Update German
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-03-30 14:01:13 +02:00
Raphael Michel eaf4a310f6 Translations: Update wordlist 2026-03-30 13:59:37 +02:00
Raphael Michel 8dc0f7c1b2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-03-30 13:26:02 +02:00
CVZ-es dd3e6c4692 Translations: Update Spanish
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-03-30 13:21:45 +02:00
Kara Engelhardt c7437336b4 Add length help text to customer password forms
Also cleans up dead code, as `validate_password` always returns None or raises a ValidationError.
2026-03-30 11:25:14 +02:00
luelista 4c0c775baa Improve 2fa type selection UI (#6031) 2026-03-27 13:47:10 +01:00
Linnea Thelander 394652a5ff Translations: Update Swedish
Currently translated at 88.0% (5530 of 6283 strings)

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

powered by weblate
2026-03-27 10:05:21 +01:00
Ivano Voghera 3f50d065ec Translations: Update Italian
Currently translated at 40.0% (2515 of 6283 strings)

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

powered by weblate
2026-03-27 10:05:21 +01:00
Ivano Voghera 4121061267 Translations: Update Italian
Currently translated at 40.0% (2515 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea Thelander aed2220139 Translations: Update Swedish
Currently translated at 76.9% (197 of 256 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea Thelander 4b2c54d38e Translations: Update Swedish
Currently translated at 88.0% (5530 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Ivano Voghera 0113a3dc1f Translations: Update Italian
Currently translated at 39.9% (2507 of 6283 strings)

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

powered by weblate
2026-03-26 18:50:24 +01:00
Linnea Thelander c12a8935f1 Translations: Update Swedish
Currently translated at 87.9% (5529 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro Isotti a86a6cc2c7 Translations: Update Italian
Currently translated at 39.5% (2486 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro Isotti fec2b9a2fc Translations: Update Italian
Currently translated at 68.3% (175 of 256 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Pietro Isotti d847a7e8f8 Translations: Update Italian
Currently translated at 39.2% (2463 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Ruud Hendrickx c58a968196 Translations: Update Dutch (Belgium)
Currently translated at 79.1% (4970 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Renne Rocha 81cbaca162 Translations: Update Portuguese (Brazil)
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Renne Rocha 218df7a49f Translations: Update Portuguese (Brazil)
Currently translated at 95.1% (5979 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Ruud Hendrickx f64343d977 Translations: Update Dutch (Belgium)
Currently translated at 78.7% (4948 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Hijiri Umemoto b36c7cbef3 Translations: Update Japanese
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
Hijiri Umemoto 18b39ba7cd Translations: Update Japanese
Currently translated at 100.0% (6283 of 6283 strings)

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

powered by weblate
2026-03-26 11:48:45 +01:00
75 changed files with 17534 additions and 16109 deletions
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.3.0.dev0" __version__ = "2026.4.0.dev0"
+1 -2
View File
@@ -196,8 +196,7 @@ class RegistrationForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email')) user = User(email=self.cleaned_data.get('email'))
if validate_password(password1, user=user) is not None: validate_password(password1, user=user)
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
def clean_email(self): def clean_email(self):
+1 -1
View File
@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
try: try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
except OutgoingMail.DoesNotExist: except OutgoingMail.DoesNotExist:
logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}") logger.info(f"Ignoring job for non existing email {outgoing_mail}")
return False return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}") logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
+67 -48
View File
@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
Voucher, SeatCategoryMapping, User, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -1618,7 +1618,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result')) 'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1632,16 +1632,24 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_position: Optional[OrderPosition] _positions: Optional[List[OrderPosition]]
def __init__(self): def __init__(self):
self._position = None self._positions = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._position is None: if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._position if len(self._positions) != 1:
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1848,8 +1856,12 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1903,14 +1915,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update(new_quotas) self._quotadiff.update({q: count for q in new_quotas})
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result)) valid_from, valid_until, is_bundled, result, count))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2530,29 +2542,35 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create( new_pos = []
item=op.item, variation=op.variation, addon_to=op.addon_to, new_logs = []
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, for i in range(op.count):
tax_value=op.price.tax, tax_rule=op.item.tax_rule, pos = OrderPosition.objects.create(
positionid=nextposid, subevent=op.subevent, seat=op.seat, item=op.item, variation=op.variation, addon_to=op.addon_to,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
is_bundled=op.is_bundled, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
) positionid=nextposid, subevent=op.subevent, seat=op.seat,
nextposid += 1 used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ is_bundled=op.is_bundled,
'position': pos.pk, )
'item': op.item.pk, nextposid += 1
'variation': op.variation.pk if op.variation else None, new_pos.append(pos)
'addon_to': op.addon_to.pk if op.addon_to else None, new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'price': op.price.gross, 'position': pos.pk,
'positionid': pos.positionid, 'item': op.item.pk,
'membership': pos.used_membership_id, 'variation': op.variation.pk if op.variation else None,
'subevent': op.subevent.pk if op.subevent else None, 'addon_to': op.addon_to.pk if op.addon_to else None,
'seat': op.seat.pk if op.seat else None, 'price': op.price.gross,
'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'positionid': pos.positionid,
'valid_until': op.valid_until.isoformat() if op.valid_until else None, 'membership': pos.used_membership_id,
}) 'subevent': op.subevent.pk if op.subevent else None,
op.result._position = pos 'seat': op.seat.pk if op.seat else None,
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2877,7 +2895,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2938,7 +2956,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -2985,17 +3003,18 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
cp = CartPosition( for i in range(op.count):
event=self.event, cp = CartPosition(
item=op.item, event=self.event,
variation=op.variation, item=op.item,
used_membership=op.membership, variation=op.variation,
subevent=op.subevent, used_membership=op.membership,
seat=op.seat, subevent=op.subevent,
) seat=op.seat,
cp.override_valid_from = op.valid_from )
cp.override_valid_until = op.valid_until cp.override_valid_from = op.valid_from
fake_cart.append(cp) cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try: try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
except ValidationError as e: except ValidationError as e:
+8
View File
@@ -331,6 +331,10 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form): class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField( itemvar = forms.ChoiceField(
label=_('Product') label=_('Product')
) )
@@ -432,6 +436,10 @@ class OrderPositionAddForm(forms.Form):
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else: else:
d['used_membership'] = None d['used_membership'] = None
if d.get("count", 1) and d.get("seat"):
raise ValidationError({
"seat": _("You can not choose a seat when adding multiple products at once.")
})
return d return d
@@ -329,6 +329,7 @@
{{ add_form.custom_error }} {{ add_form.custom_error }}
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field add_form.count layout="control" %}
{% bootstrap_field add_form.itemvar layout="control" %} {% bootstrap_field add_form.itemvar layout="control" %}
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
{% if add_form.addon_to %} {% if add_form.addon_to %}
@@ -364,6 +365,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="form-horizontal"> <div class="form-horizontal">
{% bootstrap_field add_position_formset.empty_form.count layout="control" %}
{% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %} {% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %}
{% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %}
{% if add_position_formset.empty_form.addon_to %} {% if add_position_formset.empty_form.addon_to %}
@@ -8,7 +8,45 @@
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.name layout='horizontal' %} {% bootstrap_field form.name layout='horizontal' %}
{% bootstrap_field form.devicetype layout='horizontal' %}
<div class="form-group{% if form.devicetype.errors %} has-error{% endif %}">
<label class="col-md-3 control-label">{% trans "Device type" %}</label>
<div class="col-md-9">
<div>
<div class="big-radio radio">
<label>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator.
{% endblocktrans %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Use a hardware token like the Yubikey, or other biometric authentication like fingerprint or face recognition.
{% endblocktrans %}
</div>
</label>
</div>
</div>
{% if form.devicetype.errors %}
<div class="help-block">
{% for error in form.devicetype.errors %}
<p>{{ error|escape }}</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
{% trans "Continue" %} {% trans "Continue" %}
@@ -28,11 +28,6 @@
{% trans "iOS (iTunes)" %} {% trans "iOS (iTunes)" %}
</a> </a>
</li> </li>
<li>
<a href="https://m.google.com/authenticator">
{% trans "Blackberry (Link via Google)" %}
</a>
</li>
</ul> </ul>
</li> </li>
<li> <li>
+7 -6
View File
@@ -2059,12 +2059,13 @@ class OrderChange(OrderView):
else: else:
variation = None variation = None
try: try:
ocm.add_position(item, variation, for i in range(f.cleaned_data.get("count", 1)):
f.cleaned_data['price'], ocm.add_position(item, variation,
f.cleaned_data.get('addon_to'), f.cleaned_data['price'],
f.cleaned_data.get('subevent'), f.cleaned_data.get('addon_to'),
f.cleaned_data.get('seat'), f.cleaned_data.get('subevent'),
f.cleaned_data.get('used_membership')) f.cleaned_data.get('seat'),
f.cleaned_data.get('used_membership'))
except OrderError as e: except OrderError as e:
f.custom_error = str(e) f.custom_error = str(e)
return False return False
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -32,6 +32,7 @@ ausgecheckt
ausgeklappt ausgeklappt
auswahl auswahl
Authentication Authentication
Authenticator
Authenticator-App Authenticator-App
Autorisierungscode Autorisierungscode
Autorisierungs-Endpunktes Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung Einlassbuchung
Einlassdatum Einlassdatum
Einlasskontrolle Einlasskontrolle
Einmalpasswörter
einzuchecken einzuchecken
email email
E-Mail-Renderer E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA FA
Favicon Favicon
F-Droid F-Droid
freeOTP
Footer Footer
Footer-Link Footer-Link
Footer-Text Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig Zahlungspflichtig
Zehnerkarten Zehnerkarten
Zeitbasiert Zeitbasiert
zeitbasierte
Zeitslotbuchung Zeitslotbuchung
Zimpler Zimpler
ZIP-Datei ZIP-Datei
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ ausgecheckt
ausgeklappt ausgeklappt
auswahl auswahl
Authentication Authentication
Authenticator
Authenticator-App Authenticator-App
Autorisierungscode Autorisierungscode
Autorisierungs-Endpunktes Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung Einlassbuchung
Einlassdatum Einlassdatum
Einlasskontrolle Einlasskontrolle
Einmalpasswörter
einzuchecken einzuchecken
email email
E-Mail-Renderer E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA FA
Favicon Favicon
F-Droid F-Droid
freeOTP
Footer Footer
Footer-Link Footer-Link
Footer-Text Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig Zahlungspflichtig
Zehnerkarten Zehnerkarten
Zeitbasiert Zeitbasiert
zeitbasierte
Zeitslotbuchung Zeitslotbuchung
Zimpler Zimpler
ZIP-Datei ZIP-Datei
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-30 11:25+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-03-18 12:23+0000\n" "PO-Revision-Date: 2026-03-30 03:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n" "js/es/>\n"
@@ -329,7 +329,7 @@ msgstr "Pedido no aprobado"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68
msgid "Checked-in Tickets" msgid "Checked-in Tickets"
msgstr "Registro de código QR" msgstr "Billetes registrados"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69
msgid "Valid Tickets" msgid "Valid Tickets"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -6
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-02-10 16:49+0000\n" "PO-Revision-Date: 2026-03-25 14:14+0000\n"
"Last-Translator: Raffaele Doretto <ced@comune.portogruaro.ve.it>\n" "Last-Translator: Pietro Isotti <isottipietro@gmail.com>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/it/>\n" "js/it/>\n"
"Language: it\n" "Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -310,9 +310,8 @@ msgid "Ticket code revoked/changed"
msgstr "Codice biglietto annullato/modificato" msgstr "Codice biglietto annullato/modificato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:63 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:63
#, fuzzy
msgid "Ticket blocked" msgid "Ticket blocked"
msgstr "Biglietto non pagato" msgstr "Biglietto bloccato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:64 #: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:64
msgid "Ticket not valid at this time" msgid "Ticket not valid at this time"
@@ -429,7 +428,7 @@ msgstr ""
#: pretix/static/pretixbase/js/asynctask.js:276 #: pretix/static/pretixbase/js/asynctask.js:276
msgid "If this takes longer than a few minutes, please contact us." msgid "If this takes longer than a few minutes, please contact us."
msgstr "" msgstr "Se questa operazione richiede alcuni minuti, si prega di contattarci."
#: pretix/static/pretixbase/js/asynctask.js:331 #: pretix/static/pretixbase/js/asynctask.js:331
msgid "Close message" msgid "Close message"
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-02-23 10:00+0000\n" "PO-Revision-Date: 2026-03-23 21:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n" "Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ja/>\n" "js/ja/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.16\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -60,7 +60,7 @@ msgstr "PayPal後払い"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42
msgid "SEPA Direct Debit" msgid "SEPA Direct Debit"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2026-01-26 22:00+0000\n" "PO-Revision-Date: 2026-03-25 08:00+0000\n"
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n" "Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/" "Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
"pretix/pretix-js/pt_BR/>\n" "pretix/pretix-js/pt_BR/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n > 1;\n" "Plural-Forms: nplurals=2; plural=n > 1;\n"
"X-Generator: Weblate 5.15.2\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -60,7 +60,7 @@ msgstr "PayPal Pay Later"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:41
msgid "iDEAL | Wero" msgid "iDEAL | Wero"
msgstr "" msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42 #: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:42
msgid "SEPA Direct Debit" msgid "SEPA Direct Debit"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-17 14:06+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2025-10-10 17:00+0000\n" "PO-Revision-Date: 2026-03-26 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n" "Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/sv/>\n" "js/sv/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.13.3\n" "X-Generator: Weblate 5.16.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -1023,7 +1023,6 @@ msgid "Waiting list"
msgstr "Väntelista" msgstr "Väntelista"
#: pretix/static/pretixpresale/js/widget/widget.js:55 #: pretix/static/pretixpresale/js/widget/widget.js:55
#, fuzzy
msgctxt "widget" msgctxt "widget"
msgid "" msgid ""
"You currently have an active cart for this event. If you select more " "You currently have an active cart for this event. If you select more "
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,7 @@ anonymized
Auth Auth
authentification authentification
authenticator authenticator
Authenticator
automatical automatical
availabilities availabilities
backend backend
@@ -22,6 +23,7 @@ barcodes
Bcc Bcc
BCC BCC
BezahlCode BezahlCode
biometric
BLIK BLIK
blocklist blocklist
BN BN
@@ -56,6 +58,7 @@ EPS
eps eps
favicon favicon
filetype filetype
freeOTP
frontend frontend
frontpage frontpage
Galician Galician
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -28,7 +28,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
get_password_validators, password_validators_help_texts, validate_password, MinimumLengthValidator, get_password_validators, validate_password,
) )
from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core import signing from django.core import signing
@@ -300,13 +300,12 @@ class SetPasswordForm(forms.Form):
) )
password = forms.CharField( password = forms.CharField(
label=_('Password'), label=_('Password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096, max_length=4096,
required=True
) )
password_repeat = forms.CharField( password_repeat = forms.CharField(
label=_('Repeat password'), label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096, max_length=4096,
) )
@@ -316,6 +315,14 @@ class SetPasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
pw_min_len_validators = [v for v in get_customer_password_validators() if isinstance(v, MinimumLengthValidator)]
if pw_min_len_validators:
self.fields['password'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
self.fields['password_repeat'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
if 'password' not in self.data:
self.fields['password'].help_text = ' '.join(v.get_help_text() for v in pw_min_len_validators)
def clean(self): def clean(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat') password2 = self.cleaned_data.get('password_repeat')
@@ -329,8 +336,7 @@ class SetPasswordForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None: validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
@@ -395,13 +401,13 @@ class ChangePasswordForm(forms.Form):
) )
password = forms.CharField( password = forms.CharField(
label=_('New password'), label=_('New password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096, max_length=4096,
required=True required=True
) )
password_repeat = forms.CharField( password_repeat = forms.CharField(
label=_('Repeat password'), label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}), widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096, max_length=4096,
) )
@@ -411,6 +417,14 @@ class ChangePasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
pw_min_len_validators = [v for v in get_customer_password_validators() if isinstance(v, MinimumLengthValidator)]
if pw_min_len_validators:
self.fields['password'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
self.fields['password_repeat'].widget.attrs['minlength'] = max(v.min_length for v in pw_min_len_validators)
if 'password' not in self.data:
self.fields['password'].help_text = ' '.join(v.get_help_text() for v in pw_min_len_validators)
def clean(self): def clean(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat') password2 = self.cleaned_data.get('password_repeat')
@@ -424,8 +438,7 @@ class ChangePasswordForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None: validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
def clean_password_current(self): def clean_password_current(self):
+9
View File
@@ -2406,6 +2406,15 @@ class OrderChangeManagerTests(TestCase):
self.ocm.commit() self.ocm.commit()
assert self.order.positions.count() == 2 assert self.order.positions.count() == 2
@classscope(attr='o')
def test_add_item_quota_partial(self):
q1 = self.event.quotas.create(name='Test', size=1)
q1.items.add(self.shirt)
self.ocm.add_position(self.shirt, None, None, None, count=2)
with self.assertRaises(OrderError):
self.ocm.commit()
assert self.order.positions.count() == 2
@classscope(attr='o') @classscope(attr='o')
def test_add_item_addon(self): def test_add_item_addon(self):
self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True)
+2 -1
View File
@@ -1584,10 +1584,11 @@ class OrderChangeTests(SoupTest):
'add_position-MAX_NUM_FORMS': '100', 'add_position-MAX_NUM_FORMS': '100',
'add_position-0-itemvar': str(self.shirt.pk), 'add_position-0-itemvar': str(self.shirt.pk),
'add_position-0-do': 'on', 'add_position-0-do': 'on',
'add_position-0-count': '2',
'add_position-0-price': '14.00', 'add_position-0-price': '14.00',
}) })
with scopes_disabled(): with scopes_disabled():
assert self.order.positions.count() == 3 assert self.order.positions.count() == 4
assert self.order.positions.last().item == self.shirt assert self.order.positions.last().item == self.shirt
assert self.order.positions.last().price == 14 assert self.order.positions.last().price == 14