Compare commits

..
Author SHA1 Message Date
Lukas Bockstaller a7388aa0a0 formatting 2026-04-08 12:33:02 +02:00
Lukas Bockstaller 166aa33b1b handle mixed line endings in import 2026-04-08 12:30:34 +02:00
Richard SchreiberandGitHub c037fd865b Fix multi-product order edit with seats (#6063) 2026-04-08 11:02:58 +02:00
Kara Engelhardtandpajowu 12171e0665 Fix copy-and-paste errors 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu 444963e952 tests: Remove on_commit monkeypatch 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu a57810cf41 tests: replace broken monkeypatching with TransactionTestCase 2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu 2e2e57d231 Fix typo in test detection, improve check
A non-empty string is truthy, making the the for-loop useless, as the first item in inspect.stack() is always the for-loop itself, which then lead to the function returning immediately.
This commit
* fixes this typo
* changes the loop to ignore the first element of instpect.stack() (which is the loop itself)
* ignores django-internal code

This should create something similar to what I suspect the code was intended to do originally.
2026-04-07 14:39:33 +02:00
Kara Engelhardtandpajowu fc7e8ea67a Log new properties when changing device 2026-04-07 13:28:38 +02:00
Raphael Michel 23d1673403 Fix typo 2026-04-02 21:43:36 +02:00
Raphael Michel 92d1830f3b Exporters: Pass state about staff_session 2026-04-02 21:03:42 +02:00
Raphael MichelandGitHub d411c36414 Exporters: Give access to authentication infos and allow empty permissions (#5979)
* Exporters: Give access to authentication infos

* Allow exporters to have empty permission

* Use a protocol
2026-04-02 15:44:36 +02:00
Raphael MichelandGitHub 84e12fea32 Dockerfile: Use Python 3.13 (#6028) 2026-04-02 13:18:04 +02:00
Kara Engelhardtandpajowu b6518449d6 Add placeholder for checked in addons (Z#23230009) 2026-04-02 12:06:00 +02:00
Ruud HendrickxandRaphael Michel 50c99e1239 Translations: Update Dutch (Belgium)
Currently translated at 82.8% (5210 of 6287 strings)

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

powered by weblate
2026-04-02 12:01:26 +02:00
dependabot[bot]andRaphael Michel e70452ee47 Update pillow requirement from ==12.1.* to ==12.2.*
Updates the requirements on [pillow](https://github.com/python-pillow/Pillow) to permit the latest version.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.0...12.2.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 12:01:19 +02:00
Raphael MichelandGitHub 666b496ab4 Allow to configure a readonly DB connection (#5978) 2026-04-01 13:46:52 +02:00
Richard SchreiberandGitHub 8bd0665f37 Fix password-manager username not saved on customer account creation (#6043)
* Fix password-manager username not saved on customer account creation

* Fix tests/make email not required
2026-04-01 12:00:03 +02:00
Raphael MichelandGitHub ed1459b1dd Order change form: Allow to add multiple identical positions (Z#23227479) (#6044)
* Order change form: Allow to add multiple identical positions (Z#23227479)

* New implementation
2026-04-01 11:54:48 +02:00
Raphael MichelandGitHub 8c251029b9 Fix useless cart sessions being created (#6045)
* Do not create useless cart session accessing invoice address

* Skip useless code paths in CartMixin

* Do not create cart session on view with active session

* Create regression tests
2026-04-01 09:29:14 +02:00
dependabot[bot]andRaphael Michel 531f697b9a Update redis requirement from ==7.1.* to ==7.4.*
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v7.1.0...v7.4.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 7.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 09:20:14 +02:00
Ruud HendrickxandRaphael Michel 719ad7104d Translations: Update Dutch (Belgium)
Currently translated at 82.1% (5164 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel dcb0eb765f Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
CVZ-esandRaphael Michel 86b5191e8b Translations: Update Spanish
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel b0714886bc Translations: Update Dutch
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
CVZ-esandRaphael Michel 438f70c730 Translations: Update French
Currently translated at 100.0% (6287 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Ruud HendrickxandRaphael Michel 608b150bf8 Translations: Update Dutch (Belgium)
Currently translated at 79.6% (5007 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
Renne RochaandRaphael Michel c0df7c6142 Translations: Update Portuguese (Brazil)
Currently translated at 95.1% (5980 of 6287 strings)

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

powered by weblate
2026-04-01 09:19:49 +02:00
dependabot[bot]andRaphael Michel b2ea172a60 Update sentry-sdk requirement from ==2.56.* to ==2.57.*
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.56.0...2.57.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 09:19:35 +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 Engelhardtandpajowu 49893ca9df Fix crash in mail_send_task for nonexistant mails 2026-03-30 14:57:56 +02:00
Raphael MichelandRaphael 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 MichelandRaphael 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-esandRaphael Michel 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 Engelhardtandpajowu 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
103 changed files with 17813 additions and 16590 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.11-bookworm
FROM python:3.13-trixie
RUN apt-get update && \
apt-get install -y --no-install-recommends \
+3 -3
View File
@@ -76,7 +76,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.12.*",
"phonenumberslite==9.0.*",
"Pillow==12.1.*",
"Pillow==12.2.*",
"pretix-plugin-build",
"protobuf==7.34.*",
"psycopg2-binary",
@@ -90,10 +90,10 @@ dependencies = [
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.2",
"redis==7.1.*",
"redis==7.4.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.56.*",
"sentry-sdk==2.57.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
+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
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.3.0.dev0"
__version__ = "2026.4.0.dev0"
+13 -3
View File
@@ -47,6 +47,7 @@ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event
from pretix.base.models.auth import PermissionHolder
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
@@ -59,11 +60,20 @@ class BaseExporter:
This is the base class for all data exporters
"""
def __init__(self, event, organizer, progress_callback=lambda v: None):
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
"""
:param event: Event context, can also be a queryset of events for multi-event exports
:param organizer: Organizer context
:param user: The user who triggered the export (or None).
:param token: The API token that triggered the export (or None).
:param device: The device that triggered the export (or None)
:param progress_callback: Callback function with progress
"""
self.event = event
self.organizer = organizer
self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet)
self.permission_holder = permission_holder
if isinstance(event, QuerySet):
self.events = event
self.event = None
@@ -180,7 +190,7 @@ class BaseExporter:
return True
@classmethod
def get_required_event_permission(cls) -> str:
def get_required_event_permission(cls) -> Optional[str]:
"""
The permission level required to use this exporter for events. For multi-event-exports, this will be used
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
@@ -195,7 +205,7 @@ class OrganizerLevelExportMixin:
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
@classmethod
def get_required_organizer_permission(cls) -> str:
def get_required_organizer_permission(cls) -> Optional[str]:
"""
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
allow everyone with any access to the organizer.
+1 -2
View File
@@ -196,8 +196,7 @@ class RegistrationForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email'))
if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
validate_password(password1, user=user)
return password1
def clean_email(self):
+4
View File
@@ -70,6 +70,10 @@ def parse_csv(file, length=None, mode="strict", charset=None):
except ImportError:
charset = file.charset
data = data.decode(charset or "utf-8", mode)
# remove stray linebreaks from the end of the file
data = data.rstrip("\n")
# If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n')
+9 -3
View File
@@ -29,7 +29,9 @@ import inspect
import logging
import os
import threading
from pathlib import Path
import django
from django.conf import settings
from django.db import transaction
@@ -74,10 +76,14 @@ def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ:
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
# or not.
for frame in inspect.stack():
if 'pretix/base/models/orders' in frame.filename:
for frame in inspect.stack()[1:]:
if (
'pretix/base/models/orders' in frame.filename
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
):
# Ignore model- and django-internal code
continue
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test
+21
View File
@@ -38,6 +38,7 @@ import operator
import secrets
from datetime import timedelta
from functools import reduce
from typing import Protocol
from django.conf import settings
from django.contrib.auth.models import (
@@ -67,6 +68,14 @@ class EmailAddressTakenError(IntegrityError):
pass
class PermissionHolder(Protocol):
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
...
def has_organizer_permission(self, organizer, perm_name=None, request=None):
...
class UserManager(BaseUserManager):
"""
This is the user manager for our custom user model. See the User
@@ -696,6 +705,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.teams.exists()
class UserWithStaffSession:
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
def __init__(self, user):
self.user = user
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
return True
def has_organizer_permission(self, organizer, perm_name=None, request=None):
return True
class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True)
+2 -1
View File
@@ -229,7 +229,7 @@ class Device(LoggedModel):
"""
return self._organizer_permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
@@ -238,6 +238,7 @@ class Device(LoggedModel):
:param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
+2 -2
View File
@@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel):
not kwargs.get('force_save_with_deferred_fields', None) and
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
@@ -2841,7 +2841,7 @@ class OrderPosition(AbstractPosition):
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.")
+8 -1
View File
@@ -319,6 +319,9 @@ class TeamQuerySet(models.QuerySet):
def event_permission_q(cls, perm_name):
from ..permissions import assert_valid_event_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
assert_valid_event_permission(perm_name, allow_legacy=False)
@@ -331,6 +334,9 @@ class TeamQuerySet(models.QuerySet):
def organizer_permission_q(cls, perm_name):
from ..permissions import assert_valid_organizer_permission
if perm_name is None:
return Q()
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
assert_valid_organizer_permission(perm_name, allow_legacy=False)
@@ -550,7 +556,7 @@ class TeamAPIToken(models.Model):
"""
return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
@@ -559,6 +565,7 @@ class TeamAPIToken(models.Model):
:param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
+16 -5
View File
@@ -54,7 +54,7 @@ from bidi import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Max, Min
from django.db.models import Exists, Max, Min, OuterRef
from django.db.models.fields.files import FieldFile
from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.i18n import language
from pretix.base.models import Event, Order, OrderPosition, Question
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -379,6 +379,13 @@ DEFAULT_VARIABLES = OrderedDict((
str(p) for p in generate_compressed_addon_list(op, order, ev)
])
}),
("checked_in_addons", {
"label": _("List of Checked-In Add-Ons"),
"editor_sample": _("Add-on 1\n2x Add-on 2"),
"evaluate": lambda op, order, ev: "\n".join([
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
])
}),
("organizer", {
"label": _("Organizer name"),
"editor_sample": _("Event organizer company"),
@@ -750,12 +757,16 @@ def get_program_times(op: OrderPosition, ev: Event):
])
def generate_compressed_addon_list(op, order, event):
def generate_compressed_addon_list(op, order, event, only_checked_in=False):
itemcount = defaultdict(int)
addons = [p for p in (
addon_qs = (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
) if not p.canceled]
)
if only_checked_in:
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
addons = [p for p in addon_qs if not p.canceled]
for pos in addons:
itemcount[pos.item, pos.variation] += 1
+19 -3
View File
@@ -40,6 +40,7 @@ from pretix.base.models import (
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name,
)
from pretix.base.models.auth import UserWithStaffSession
from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail
from pretix.base.services.tasks import (
@@ -211,7 +212,12 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None
if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
continue
exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs)
exporter: BaseExporter = response(
event=event,
organizer=event.organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs
)
if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue
@@ -243,7 +249,12 @@ def init_organizer_exporters(
continue
if issubclass(response, OrganizerLevelExportMixin):
exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs)
exporter: BaseExporter = response(
event=Event.objects.none(),
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
try:
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
@@ -295,7 +306,12 @@ def init_organizer_exporters(
if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
continue
exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs)
exporter: BaseExporter = response(
event=_event_list_cache[permission_name],
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue
+1 -1
View File
@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
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
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
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.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
Voucher,
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
@@ -1618,7 +1618,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
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',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1632,16 +1632,24 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult:
_position: Optional[OrderPosition]
_positions: Optional[List[OrderPosition]]
def __init__(self):
self._position = None
self._positions = None
@property
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.")
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):
self.order = order
@@ -1848,8 +1856,12 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = 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 count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat:
seat = None
else:
@@ -1903,14 +1915,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross
self._quotadiff.update(new_quotas)
self._totaldiff_guesstimate += price.gross * count
self._quotadiff.update({q: count for q in new_quotas})
if seat:
self._seatdiff.update([seat])
result = self.AddPositionResult()
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
def split(self, position: OrderPosition):
@@ -2530,29 +2542,35 @@ class OrderChangeManager:
secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'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,
})
op.result._position = pos
new_pos = []
new_logs = []
for i in range(op.count):
pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
is_bundled=op.is_bundled,
)
nextposid += 1
new_pos.append(pos)
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'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):
position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position)
@@ -2877,7 +2895,7 @@ class OrderChangeManager:
return total
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(
self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2938,7 +2956,7 @@ class OrderChangeManager:
]) + len([
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:
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:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
for i in range(op.count):
cp = CartPosition(
event=self.event,
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
cp.override_valid_from = op.valid_from
cp.override_valid_until = op.valid_until
fake_cart.append(cp)
try:
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:
-127
View File
@@ -1528,133 +1528,6 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True)
class QuotaFilterForm(FilterForm):
orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
date_from = forms.DateField(
label=_('Date from'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date from'),
}),
)
date_until = forms.DateField(
label=_('Date until'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date until'),
}),
)
time_from = forms.TimeField(
label=_('Start time from'),
required=False,
widget=TimePickerWidget({}),
)
time_until = forms.TimeField(
label=_('Start time until'),
required=False,
widget=TimePickerWidget({}),
)
weekday = forms.MultipleChoiceField(
label=_('Weekday'),
choices=(
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
widget=forms.CheckboxSelectMultiple,
required=False
)
query = forms.CharField(
label=_('Quota name'),
widget=forms.TextInput(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['date_from'].widget = DatePickerWidget()
self.fields['date_until'].widget = DatePickerWidget()
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
del self.fields['date_from']
del self.fields['date_until']
del self.fields['time_from']
del self.fields['time_until']
del self.fields['weekday']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata["subevent"])
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(name__icontains=query)
if fdata.get('date_until'):
date_end = make_aware(datetime.combine(
fdata.get('date_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(
Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
)
if fdata.get('date_from'):
date_start = make_aware(datetime.combine(
fdata.get('date_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(subevent__date_from__gte=date_start)
if fdata.get('time_until'):
qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
if fdata.get('time_from'):
qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
else:
qs = qs.order_by('-subevent__date_from', 'name', 'pk')
return qs
class OrganizerFilterForm(FilterForm):
orders = {
'slug': 'slug',
-55
View File
@@ -43,7 +43,6 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
@@ -376,60 +375,6 @@ class QuotaForm(I18nModelForm):
return inst
class QuotaBulkEditForm(QuotaForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
self.fields["name"].required = False
self.fields["itemvars"].required = False
def clean(self):
d = super().clean()
if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
raise ValidationError({"name": _("This field is required.")})
if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
raise ValidationError({"itemvars": _("This field is required.")})
return d
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
for k in self.fields:
cb_val = self.prefix + k
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
if k == 'itemvars':
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
for obj in objs:
obj.items.set(selected_items)
obj.variations.set(selected_variations)
else:
for obj in objs:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Quota.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class ItemCreateForm(I18nModelForm):
NONE = 'none'
EXISTING = 'existing'
+8
View File
@@ -331,6 +331,10 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField(
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]
else:
d['used_membership'] = None
if d.get("count", 1) > 1 and d.get("seat"):
raise ValidationError({
"seat": _("You can not choose a seat when adding multiple products at once.")
})
return d
@@ -1,49 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>
{% trans "Change multiple quotas" %}
<small>
{% blocktrans trimmed with number=quotas.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in quotas %}
<input type="hidden" name="quota" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="bulkedit" %}
{% bootstrap_field form.size layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Items" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the products or product variations this quota should be applied to. If you apply two
quotas to the same product, it will only be available if <strong>both</strong> quotas have capacity
left.
{% endblocktrans %}
</p>
{% bootstrap_field form.itemvars layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced options" %}</legend>
{% bootstrap_field form.close_when_sold_out layout="bulkedit" %}
{% bootstrap_field form.release_after_exit layout="bulkedit" %}
{% bootstrap_field form.ignore_for_event_availability layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,34 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete quotas" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete quotas" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if allowed %}
<p>{% blocktrans trimmed count num=allowed|length %}
Are you sure you want to delete the following quota?
{% plural %}
Are you sure you want to delete the following {{ num }} quotas?
{% endblocktrans %}</p>
<ul>
{% for q in allowed %}
<li>
{{ q }} {% if q.subevent %}({{ q.subevent }}){% endif %}
<input type="hidden" name="quota" value="{{ q.pk }}">
</li>
{% endfor %}
</ul>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save" value="delete_confirm" name="action">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,7 +1,6 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %}
<h1>{% trans "Quotas" %}</h1>
@@ -14,12 +13,21 @@
number of a specific ticket type at the same time.
{% endblocktrans %}
</p>
{% if quotas|length == 0 and not filter_form.filtered %}
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if quotas|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
{% if request.GET.subevent %}
{% trans "Your search did not match any quotas." %}
{% else %}
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
{% endif %}
</p>
{% if 'event.items:write' in request.eventpermset %}
@@ -28,160 +36,79 @@
{% endif %}
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Filter" %}
</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="{% if not filter_form.subevent %}col-lg-6{% else %}col-lg-2{% endif %} col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
{% if filter_form.subevent %}
<div class="col-lg-2 col-md-6 col-md-2 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.subevent %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_until %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_until %}
</div>
<div class="col-xs-12 one-line-checkboxes">
{% bootstrap_field filter_form.weekday %}
</div>
{% endif %}
</div>
<div class="text-right flip">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}
</a>
</p>
{% endif %}
<form action="{% url "control:event.items.quotas.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
{% if "event.items:write" in request.eventpermset %}
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}" class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
{% endif %}
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'filter-ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'filter-ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'filter-ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
{% if "event.items:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for q in quotas %}
<tr>
{% if "event.items:write" in request.eventpermset %}
<td>
<label aria-label="{% trans "select row for batch-operation" %}" class="batch-select-label"><input type="checkbox" name="quota" class="batch-select-checkbox" value="{{ q.pk }}"/></label>
</td>
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for q in quotas %}
<tr>
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if "event.items:write" in request.eventpermset %}
<div class="batch-select-actions">
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.items.quotas.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
@@ -329,6 +329,7 @@
{{ add_form.custom_error }}
</div>
{% endif %}
{% bootstrap_field add_form.count layout="control" %}
{% bootstrap_field add_form.itemvar layout="control" %}
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
{% if add_form.addon_to %}
@@ -364,6 +365,7 @@
</div>
<div class="panel-body">
<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.price addon_after=request.event.currency layout="control" %}
{% if add_position_formset.empty_form.addon_to %}
-2
View File
@@ -349,8 +349,6 @@ urlpatterns = [
name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
+28 -199
View File
@@ -41,22 +41,21 @@ from json.decoder import JSONDecodeError
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import models, transaction
from django.db import transaction
from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Subquery, Value,
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
)
from django.db.models.functions import Cast, Concat
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
)
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, View
from django.views.generic import ListView
from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country
@@ -66,7 +65,7 @@ from pretix.api.serializers.item import (
)
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry,
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
@@ -75,15 +74,12 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
from pretix.control.forms.filter import (
QuestionAnswerFilterForm, QuotaFilterForm,
)
from pretix.control.forms.filter import QuestionAnswerFilterForm
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaBulkEditForm,
QuotaForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -91,7 +87,6 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -836,38 +831,13 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret
class QuotaQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_queryset(self):
qs = self.request.event.quotas
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if 'quota' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('quota')
)
return qs
@cached_property
def filter_form(self):
return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
class QuotaList(PaginationMixin, ListView):
model = Quota
context_object_name = 'quotas'
template_name = 'pretixcontrol/items/quotas.html'
def get_queryset(self):
return super().get_queryset().prefetch_related(
qs = self.request.event.quotas.prefetch_related(
Prefetch(
"items",
queryset=Item.objects.annotate(
@@ -882,10 +852,28 @@ class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
queryset=self.request.event.subevents.all()
)
)
if self.request.GET.get("subevent", "") != "":
s = self.request.GET.get("subevent", "")
qs = qs.filter(subevent_id=s)
valid_orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
if self.request.GET.get("ordering", "-date") in valid_orders:
qs = qs.order_by(*valid_orders[self.request.GET.get("ordering", "-date")])
else:
qs = qs.order_by('name', 'subevent__date_from', 'pk')
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
qa = QuotaAvailability()
qa.queue(*ctx['quotas'])
@@ -896,165 +884,6 @@ class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
return ctx
class QuotaBulkAction(QuotaQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.items:write'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/items/quota_delete_bulk.html', {
'allowed': self.get_queryset().select_related("subevent"),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.get_queryset():
log_entries.append(obj.log_action('pretix.event.quota.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
if to_delete:
LogEntry.bulk_create_and_postprocess(log_entries)
Quota.objects.filter(pk__in=to_delete).delete()
messages.success(request, _('The selected quotas have been deleted or disabled.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class QuotaBulkUpdateView(QuotaQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/items/quota_bulk_edit.html'
permission = 'event.items:write'
context_object_name = 'quota'
form_class = QuotaBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behaviour
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate(
items_list=Subquery(
Quota.items.through.objects.filter(
quota_id=OuterRef('pk'),
item__variations__isnull=True,
).order_by().values('quota_id').annotate(
g=GroupConcat('item_id', separator=',', ordered=True)
).values('g')
),
vars_list=Subquery(
Quota.variations.through.objects.filter(
quota_id=OuterRef('pk')
).order_by().values('quota_id').annotate(
g=GroupConcat(
Concat(
Cast(F('itemvariation__item_id'), output_field=models.TextField()),
Value('-', output_field=models.TextField()),
Cast(F('itemvariation_id'), output_field=models.TextField()),
),
separator=',',
ordered=True
)
).values('g')
),
)
fields = {
'name': 'name',
'size': 'size',
'subevent': 'subevent',
'close_when_sold_out': 'close_when_sold_out',
'release_after_exit': 'release_after_exit',
'ignore_for_event_availability': 'ignore_for_event_availability',
}
for k, f in fields.items():
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[k] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(k)
initial[k] = None
item_values = list(qs.order_by("items_list").values("items_list").annotate(c=Count('*')))
var_values = list(qs.order_by("vars_list").values("vars_list").annotate(c=Count('*')))
if len(item_values) > 1 or len(var_values) > 1:
mixed_values.add("itemvars")
else:
initial["itemvars"] = [iv for iv in (item_values[0]["items_list"] or "").split(",") + (var_values[0]["vars_list"] or "").split(",") if iv]
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@transaction.atomic()
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.event.quota.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['quotas'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota
form_class = QuotaForm
+7 -6
View File
@@ -2059,12 +2059,13 @@ class OrderChange(OrderView):
else:
variation = None
try:
ocm.add_position(item, variation,
f.cleaned_data['price'],
f.cleaned_data.get('addon_to'),
f.cleaned_data.get('subevent'),
f.cleaned_data.get('seat'),
f.cleaned_data.get('used_membership'))
for i in range(f.cleaned_data.get("count", 1)):
ocm.add_position(item, variation,
f.cleaned_data['price'],
f.cleaned_data.get('addon_to'),
f.cleaned_data.get('subevent'),
f.cleaned_data.get('seat'),
f.cleaned_data.get('used_membership'))
except OrderError as e:
f.custom_error = str(e)
return False
+1 -1
View File
@@ -1322,7 +1322,7 @@ class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.device.changed', user=self.request.user, data={
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in form.cleaned_data[k]]
for k in form.changed_data
})
+1 -6
View File
@@ -117,17 +117,12 @@ class GroupConcat(Aggregate):
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
else:
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
template, params = super().as_sql(
return super().as_sql(
compiler, connection,
function='string_agg',
template=template,
**extra_context,
)
if self.ordered:
# ordered statement requires field parameters twice
params = params + params
return template, params
class ReplicaRouter:
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
auswahl
Authentication
Authenticator
Authenticator-App
Autorisierungscode
Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung
Einlassdatum
Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA
Favicon
F-Droid
freeOTP
Footer
Footer-Link
Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig
Zehnerkarten
Zeitbasiert
zeitbasierte
Zeitslotbuchung
Zimpler
ZIP-Datei
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,7 @@ ausgecheckt
ausgeklappt
auswahl
Authentication
Authenticator
Authenticator-App
Autorisierungscode
Autorisierungs-Endpunktes
@@ -130,6 +131,7 @@ Eingangsscan
Einlassbuchung
Einlassdatum
Einlasskontrolle
Einmalpasswörter
einzuchecken
email
E-Mail-Renderer
@@ -163,6 +165,7 @@ Explorer
FA
Favicon
F-Droid
freeOTP
Footer
Footer-Link
Footer-Text
@@ -557,6 +560,7 @@ Zahlungs-ID
Zahlungspflichtig
Zehnerkarten
Zeitbasiert
zeitbasierte
Zeitslotbuchung
Zimpler
ZIP-Datei
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\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"
"Report-Msgid-Bugs-To: \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"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n"
@@ -329,7 +329,7 @@ msgstr "Pedido no aprobado"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:68
msgid "Checked-in Tickets"
msgstr "Registro de código QR"
msgstr "Billetes registrados"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:69
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
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
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
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,7 @@ anonymized
Auth
authentification
authenticator
Authenticator
automatical
availabilities
backend
@@ -22,6 +23,7 @@ barcodes
Bcc
BCC
BezahlCode
biometric
BLIK
blocklist
BN
@@ -56,6 +58,7 @@ EPS
eps
favicon
filetype
freeOTP
frontend
frontpage
Galician
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -11
View File
@@ -28,7 +28,7 @@ from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
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.core import signing
@@ -296,17 +296,17 @@ class SetPasswordForm(forms.Form):
}
email = forms.EmailField(
label=_('Email'),
disabled=True
widget=forms.EmailInput(attrs={'autocomplete': 'username', 'readonly': 'readonly'}),
required=False,
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096,
)
@@ -316,6 +316,14 @@ class SetPasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email
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):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
@@ -329,8 +337,7 @@ class SetPasswordForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
return password1
@@ -395,13 +402,13 @@ class ChangePasswordForm(forms.Form):
)
password = forms.CharField(
label=_('New password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
max_length=4096,
)
@@ -411,6 +418,14 @@ class ChangePasswordForm(forms.Form):
kwargs['initial']['email'] = self.customer.email
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):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
@@ -424,8 +439,7 @@ class ChangePasswordForm(forms.Form):
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer, password_validators=get_customer_password_validators()) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
validate_password(password1, user=self.customer, password_validators=get_customer_password_validators())
return password1
def clean_password_current(self):
+26 -10
View File
@@ -70,18 +70,21 @@ def cached_invoice_address(request):
# do not create a session, if we don't have a session we also don't have an invoice address ;)
request._checkout_flow_invoice_address = InvoiceAddress()
return request._checkout_flow_invoice_address
cs = cart_session(request)
iapk = cs.get('invoice_address')
if not iapk:
cs = cart_session(request, create=False)
if cs is None:
request._checkout_flow_invoice_address = InvoiceAddress()
else:
try:
with scopes_disabled():
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(
pk=iapk, order__isnull=True
)
except InvoiceAddress.DoesNotExist:
iapk = cs.get('invoice_address')
if not iapk:
request._checkout_flow_invoice_address = InvoiceAddress()
else:
try:
with scopes_disabled():
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(
pk=iapk, order__isnull=True
)
except InvoiceAddress.DoesNotExist:
request._checkout_flow_invoice_address = InvoiceAddress()
return request._checkout_flow_invoice_address
@@ -111,6 +114,12 @@ class CartMixin:
return cached_invoice_address(self.request)
def get_cart(self, answers=False, queryset=None, order=None, downloads=False, payments=None):
if not self.request.session.session_key and not order:
# The user has not even a session ID yet, so they can't have a cart and we can save a lot of work
return {
'positions': [],
# Other keys are not used on non-checkout pages
}
if queryset is not None:
prefetch = []
if answers:
@@ -166,7 +175,8 @@ class CartMixin:
else:
fees = []
if not order:
if not order and lcp:
# Do not re-round for empty cart (useless) or confirmed order (incorrect)
apply_rounding(self.request.event.settings.tax_rounding, self.invoice_address, self.request.event.currency, [*lcp, *fees])
total = sum([c.price for c in lcp]) + sum([f.value for f in fees])
@@ -277,6 +287,12 @@ class CartMixin:
}
def current_selected_payments(self, positions, fees, invoice_address, *, warn=False):
from pretix.presale.views.cart import get_or_create_cart_id
if not get_or_create_cart_id(self.request, create=False):
# No active cart ID, no payments there
return []
raw_payments = copy.deepcopy(self.cart_session.get('payments', []))
fees = [f for f in fees if f.fee_type != OrderFee.FEE_TYPE_PAYMENT] # we re-compute these here
+4 -2
View File
@@ -417,7 +417,7 @@ def get_or_create_cart_id(request, create=True):
return new_id
def cart_session(request):
def cart_session(request, create=True):
"""
Before pretix 1.8.0, all checkout-related information (like the entered email address) was stored
in the user's regular session dictionary. This led to data interference and leaks for example if a
@@ -428,7 +428,9 @@ def cart_session(request):
active cart session sub-dictionary for read and write access.
"""
request.session.modified = True
cart_id = get_or_create_cart_id(request)
cart_id = get_or_create_cart_id(request, create=create)
if not cart_id and not create:
return None
return request.session['carts'][cart_id]
+16 -1
View File
@@ -157,7 +157,7 @@ DATABASES = {
'HOST': config.get('database', 'host', fallback=''),
'PORT': config.get('database', 'port', fallback=''),
'CONN_MAX_AGE': 0 if db_backend == 'sqlite3' else 120,
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3', # Will only be used from Django 4.1 onwards
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3',
'DISABLE_SERVER_SIDE_CURSORS': db_disable_server_side_cursors,
'OPTIONS': db_options,
'TEST': {}
@@ -179,6 +179,21 @@ if config.has_section('replica'):
}
DATABASE_ROUTERS = ['pretix.helpers.database.ReplicaRouter']
if config.has_section('dbreadonly'):
DATABASES['readonly'] = {
'ENGINE': 'django.db.backends.' + db_backend,
'NAME': config.get('dbreadonly', 'name', fallback=DATABASES['default']['NAME']),
'USER': config.get('dbreadonly', 'user', fallback=DATABASES['default']['USER']),
'PASSWORD': config.get('dbreadonly', 'password', fallback=DATABASES['default']['PASSWORD']),
'HOST': config.get('dbreadonly', 'host', fallback=DATABASES['default']['HOST']),
'PORT': config.get('dbreadonly', 'port', fallback=DATABASES['default']['PORT']),
'CONN_MAX_AGE': 0, # do not spam primary with open connections as long as readonly is only used occasionally
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3',
'DISABLE_SERVER_SIDE_CURSORS': db_disable_server_side_cursors,
'OPTIONS': db_options,
'TEST': {}
}
STATIC_URL = config.get('urls', 'static', fallback='/static/')
MEDIA_URL = config.get('urls', 'media', fallback='/media/')
+2 -2
View File
@@ -2053,7 +2053,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['positions'][0].get('pdf_data')
# order list
with django_assert_max_num_queries(33):
with django_assert_max_num_queries(34):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
))
@@ -2068,7 +2068,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['results'][0]['positions'][0].get('pdf_data')
# position list
with django_assert_max_num_queries(35):
with django_assert_max_num_queries(36):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format(
organizer.slug, event.slug
))
+27
View File
@@ -991,3 +991,30 @@ def test_import_mixed_order_size_consistency(user, event, item):
).get()
assert ('Inconsistent data in row 2: Column Email address contains value "a2@example.com", but for this order, '
'the value has already been set to "a1@example.com".') in str(excinfo.value)
@pytest.mark.django_db
@scopes_disabled()
def test_import_line_endings_mix(event, item, user):
# Ensures import works with mixed file endings.
# See Ticket#23230806 where a file to import ends with \r\n
settings = dict(DEFAULT_SETTINGS)
settings['item'] = 'static:{}'.format(item.pk)
cf = inputfile_factory()
file = cf.file
file.seek(0)
data = file.read()
data = data.replace(b'\n', b'\r')
data = data.rstrip(b'\r\r')
data = data + b'\r\n'
print(data)
cf.file.save("input.csv", ContentFile(data))
cf.save()
import_orders.apply(
args=(event.pk, cf.id, settings, 'en', user.pk)
)
assert event.orders.count() == 3
assert OrderPosition.objects.count() == 3
+12 -18
View File
@@ -24,7 +24,6 @@ from decimal import Decimal
import pytest
from django.core import mail as djmail
from django.db import transaction
from django.utils.timezone import now
from django_scopes import scope
@@ -75,47 +74,42 @@ def user(team):
return user
@pytest.fixture
def monkeypatch_on_commit(monkeypatch):
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@pytest.mark.django_db
def test_notification_trigger_event_specific(event, order, user, monkeypatch_on_commit):
def test_notification_trigger_event_specific(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 1
assert djmail.outbox[0].subject.endswith("DUMMY: Order FOO has been marked as paid.")
@pytest.mark.django_db
def test_notification_trigger_global(event, order, user, monkeypatch_on_commit):
def test_notification_trigger_global(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_trigger_global_wildcard(event, order, user, monkeypatch_on_commit):
def test_notification_trigger_global_wildcard(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.changed.*', enabled=True
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.changed.item', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_enabled_global_ignored_specific(event, order, user, monkeypatch_on_commit):
def test_notification_enabled_global_ignored_specific(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
@@ -123,24 +117,24 @@ def test_notification_enabled_global_ignored_specific(event, order, user, monkey
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=False
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_notification_ignore_same_user(event, order, user, monkeypatch_on_commit):
def test_notification_ignore_same_user(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {}, user=user)
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_notification_ignore_insufficient_permissions(event, order, user, team, monkeypatch_on_commit):
def test_notification_ignore_insufficient_permissions(event, order, user, team, django_capture_on_commit_callbacks):
djmail.outbox = []
team.all_event_permissions = False
team.limit_event_permissions = {"event.vouchers:read": True}
@@ -148,7 +142,7 @@ def test_notification_ignore_insufficient_permissions(event, order, user, team,
user.notification_settings.create(
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 0
+32 -26
View File
@@ -28,8 +28,9 @@ from zoneinfo import ZoneInfo
import pytest
from django.conf import settings
from django.core import mail as djmail
from django.db import transaction
from django.db.models import F, Sum
from django.test import TestCase, override_settings
from django.test import TestCase, TransactionTestCase, override_settings
from django.utils.timezone import make_aware, now
from django_countries.fields import Country
from django_scopes import scope
@@ -1225,12 +1226,6 @@ class DownloadReminderTests(TestCase):
assert len(djmail.outbox) == 0
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class OrderCancelTests(TestCase):
def setUp(self):
super().setUp()
@@ -1258,7 +1253,6 @@ class OrderCancelTests(TestCase):
self.order.create_transactions()
generate_invoice(self.order)
djmail.outbox = []
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@classscope(attr='o')
def test_cancel_canceled(self):
@@ -1351,14 +1345,14 @@ class OrderCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
djmail.outbox = []
cancel_order(self.order.pk, send_mail=True)
print([s.subject for s in djmail.outbox])
print([s.to for s in djmail.outbox])
with self.captureOnCommitCallbacks(execute=True):
cancel_order(self.order.pk, send_mail=True)
assert len(djmail.outbox) == 2
assert ["invoice@example.org"] == djmail.outbox[0].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
assert ["dummy@dummy.test"] == djmail.outbox[1].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
assert ["dummy@dummy.test"] == djmail.outbox[0].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
assert ["invoice@example.org"] == djmail.outbox[1].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
@classscope(attr='o')
def test_cancel_paid_with_too_high_fee(self):
@@ -1488,8 +1482,7 @@ class OrderCancelTests(TestCase):
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
@pytest.mark.usefixtures("class_monkeypatch")
class OrderChangeManagerTests(TestCase):
class BaseOrderChangeManagerTestCase:
def setUp(self):
super().setUp()
self.o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
@@ -1552,7 +1545,6 @@ class OrderChangeManagerTests(TestCase):
self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1")
self.seat_a2 = self.event.seats.create(seat_number="A2", product=self.stalls, seat_guid="A2")
self.seat_a3 = self.event.seats.create(seat_number="A3", product=self.stalls, seat_guid="A3")
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
def _enable_reverse_charge(self):
self.tr7.eu_reverse_charge = True
@@ -1566,6 +1558,8 @@ class OrderChangeManagerTests(TestCase):
country=Country('AT')
)
class OrderChangeManagerTests(BaseOrderChangeManagerTestCase, TestCase):
@classscope(attr='o')
def test_multiple_commits_forbidden(self):
self.ocm.change_price(self.op1, Decimal('10.00'))
@@ -2406,6 +2400,15 @@ class OrderChangeManagerTests(TestCase):
self.ocm.commit()
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')
def test_add_item_addon(self):
self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True)
@@ -3895,15 +3898,16 @@ class OrderChangeManagerTests(TestCase):
@classscope(attr='o')
def test_set_valid_until(self):
self.event.settings.ticket_secret_generator = "pretix_sig1"
assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True)
old_secret = self.op1.secret
with transaction.atomic():
self.event.settings.ticket_secret_generator = "pretix_sig1"
assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True)
old_secret = self.op1.secret
dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0))
self.ocm.change_valid_until(self.op1, dt)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.secret != old_secret
dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0))
self.ocm.change_valid_until(self.op1, dt)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.secret != old_secret
@classscope(attr='o')
def test_unset_valid_from_until(self):
@@ -3928,6 +3932,8 @@ class OrderChangeManagerTests(TestCase):
assert len(djmail.outbox) == 1
assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 2
class OrderChangeManagerTransactionalTests(BaseOrderChangeManagerTestCase, TransactionTestCase):
@classscope(attr='o')
def test_new_invoice_send_somewhere_else(self):
generate_invoice(self.order)
+16 -22
View File
@@ -25,7 +25,6 @@ from decimal import Decimal
import pytest
import responses
from django.db import transaction
from django.utils.timezone import now
from django_scopes import scopes_disabled
@@ -82,14 +81,9 @@ def force_str(v):
return v.decode() if isinstance(v, bytes) else str(v)
@pytest.fixture
def monkeypatch_on_commit(monkeypatch):
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_commit):
def test_webhook_trigger_event_specific(event, order, webhook, django_capture_on_commit_callbacks):
responses.add_callback(
responses.POST, 'https://google.com',
callback=lambda r: (200, {}, 'ok'),
@@ -97,7 +91,7 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co
match_querystring=None, # https://github.com/getsentry/responses/issues/464
)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
le = order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
@@ -119,12 +113,12 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit):
def test_webhook_trigger_global(event, order, webhook, django_capture_on_commit_callbacks):
webhook.limit_events.clear()
webhook.all_events = True
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
le = order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
@@ -138,13 +132,13 @@ def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit):
@pytest.mark.django_db
@responses.activate
def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_commit):
def test_webhook_trigger_global_wildcard(event, order, webhook, django_capture_on_commit_callbacks):
webhook.listeners.create(action_type="pretix.event.order.changed.*")
webhook.limit_events.clear()
webhook.all_events = True
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
le = order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 1
assert json.loads(force_str(responses.calls[0].request.body)) == {
@@ -158,30 +152,30 @@ def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_c
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_wrong_action_type(event, order, webhook, monkeypatch_on_commit):
def test_webhook_ignore_wrong_action_type(event, order, webhook, django_capture_on_commit_callbacks):
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_disabled(event, order, webhook, monkeypatch_on_commit):
def test_webhook_ignore_disabled(event, order, webhook, django_capture_on_commit_callbacks):
webhook.enabled = False
webhook.save()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@pytest.mark.django_db
@responses.activate
def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit):
def test_webhook_ignore_wrong_event(event, order, webhook, django_capture_on_commit_callbacks):
webhook.limit_events.clear()
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.changed.item', {})
assert len(responses.calls) == 0
@@ -189,10 +183,10 @@ def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit
@pytest.mark.django_db
@pytest.mark.xfail(reason="retries can't be tested with celery_always_eager")
@responses.activate
def test_webhook_retry(event, order, webhook, monkeypatch_on_commit):
def test_webhook_retry(event, order, webhook, django_capture_on_commit_callbacks):
responses.add(responses.POST, 'https://google.com', status=500)
responses.add(responses.POST, 'https://google.com', status=200)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 2
with scopes_disabled():
@@ -216,9 +210,9 @@ def test_webhook_retry(event, order, webhook, monkeypatch_on_commit):
@pytest.mark.django_db
@responses.activate
def test_webhook_disable_gone(event, order, webhook, monkeypatch_on_commit):
def test_webhook_disable_gone(event, order, webhook, django_capture_on_commit_callbacks):
responses.add(responses.POST, 'https://google.com', status=410)
with transaction.atomic():
with django_capture_on_commit_callbacks(execute=True):
order.log_action('pretix.event.order.paid', {})
assert len(responses.calls) == 1
webhook.refresh_from_db()
+5
View File
@@ -131,3 +131,8 @@ def set_lock_namespaces(request):
yield
else:
yield
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
-5
View File
@@ -385,11 +385,6 @@ class RegistrationFormTest(TestCase):
self.assertEqual(response.status_code, 403)
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class Login2FAFormTest(TestCase):
-5
View File
@@ -49,11 +49,6 @@ from tests.base import SoupTest, extract_form_fields
from pretix.base.models import Event, LogEntry, Order, Organizer, Team, User
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class EventsTest(SoupTest):
@scopes_disabled()
+2 -1
View File
@@ -1584,10 +1584,11 @@ class OrderChangeTests(SoupTest):
'add_position-MAX_NUM_FORMS': '100',
'add_position-0-itemvar': str(self.shirt.pk),
'add_position-0-do': 'on',
'add_position-0-count': '2',
'add_position-0-price': '14.00',
})
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().price == 14
-5
View File
@@ -33,11 +33,6 @@ from tests.base import SoupTest, extract_form_fields
from pretix.base.models import Event, Organizer, OutgoingMail, Team, User
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class OrganizerTest(SoupTest):
@scopes_disabled()

Some files were not shown because too many files have changed in this diff Show More