diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 5c2e74b2b..b17aee56c 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -294,6 +294,10 @@ Example:: setting is not provided, pretix will generate a random secret on the first start and will store it in the filesystem for later usage. +``secret_fallback0`` ... ``secret_fallback9`` + Prior versions of the secret to be used by Django for signing and verification purposes that will still + be accepted but no longer be used for new signing. + ``debug`` Whether or not to run in debug mode. Default is ``False``. diff --git a/doc/api/resources/saleschannels.rst b/doc/api/resources/saleschannels.rst index 3a6604eab..8b6e5871c 100644 --- a/doc/api/resources/saleschannels.rst +++ b/doc/api/resources/saleschannels.rst @@ -51,7 +51,7 @@ Endpoints "results": [ { "identifier": "web", - "name": { + "label": { "en": "Online shop" }, "type": "web", @@ -88,7 +88,7 @@ Endpoints { "identifier": "web", - "name": { + "label": { "en": "Online shop" }, "type": "web", @@ -116,7 +116,7 @@ Endpoints { "identifier": "api.custom", - "name": { + "label": { "en": "Custom integration" }, "type": "api", @@ -133,7 +133,7 @@ Endpoints { "identifier": "api.custom", - "name": { + "label": { "en": "Custom integration" }, "type": "api", @@ -178,7 +178,7 @@ Endpoints { "identifier": "web", - "name": { + "label": { "en": "Online shop" }, "type": "web", diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index dafae3fc3..8c3f2902c 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -20,8 +20,9 @@ internal_name string An optional nam rate decimal (string) Tax rate in percent price_includes_tax boolean If ``true`` (default), tax is assumed to be included in the specified product price -eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied. Will - be ignored if custom rules are set. +eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules + are applied. Will be ignored if custom rules are set. + Use custom rules instead. home_country string Merchant country (required for reverse charge), can be ``null`` or empty string keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index f7b6ef800..5b558ef96 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -14,7 +14,7 @@ Core :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter, register_ticket_secret_generators, gift_card_transaction_display, - register_text_placeholders, register_mail_placeholders + register_text_placeholders, register_mail_placeholders, device_info_updated Order events """""""""""" diff --git a/doc/user/customers/index.rst b/doc/user/customers/index.rst index bbe7c42c6..5a6d4a46a 100644 --- a/doc/user/customers/index.rst +++ b/doc/user/customers/index.rst @@ -175,7 +175,7 @@ without any special behavior. Connecting SSO providers (pretix as the SSO client) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To connect an external application as a SSO client, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider" +To connect an external application as a SSO provider, go to "Customer accounts" → "SSO providers" → "Create a new SSO provider" in your organizer account. .. thumbnail:: ../../screens/organizer/customer_ssoprovider_add.png diff --git a/pyproject.toml b/pyproject.toml index 18efc8a45..7a382bc35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "css-inline==0.14.*", "defusedcsv>=1.1.0", "Django[argon2]==4.2.*,>=4.2.15", - "django-bootstrap3==24.2", + "django-bootstrap3==24.3", "django-compressor==4.5.1", "django-countries==7.6.*", "django-filter==24.3", @@ -80,18 +80,18 @@ dependencies = [ "psycopg2-binary", "pycountry", "pycparser==2.22", - "pycryptodome==3.20.*", - "pypdf==4.3.*", + "pycryptodome==3.21.*", + "pypdf==5.0.*", "python-bidi==0.6.*", # Support for Arabic in reportlab "python-dateutil==2.9.*", "pytz", "pytz-deprecation-shim==0.1.*", "pyuca", - "qrcode==7.4.*", - "redis==5.0.*", + "qrcode==8.0", + "redis==5.1.*", "reportlab==4.2.*", "requests==2.31.*", - "sentry-sdk==2.13.*", + "sentry-sdk==2.15.*", "sepaxml==2.6.*", "slimit", "stripe==7.9.*", diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 4a748f45b..1bf5b50ea 100644 --- a/src/pretix/__init__.py +++ b/src/pretix/__init__.py @@ -19,4 +19,4 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -__version__ = "2024.9.0.dev0" +__version__ = "2024.10.0.dev0" diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py index dff4bbc5b..cf1ce268b 100644 --- a/src/pretix/api/serializers/__init__.py +++ b/src/pretix/api/serializers/__init__.py @@ -88,16 +88,20 @@ class SalesChannelMigrationMixin: } if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels: - raise ValidationError( - "If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " - "the list of all sales channels." - ) + raise ValidationError({ + "limit_sales_channels": [ + "If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " + "the list of all sales channels." + ] + }) if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]): - raise ValidationError( - "If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " - "the same list." - ) + raise ValidationError({ + "limit_sales_channels": [ + "If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " + "the same list." + ] + }) if data["sales_channels"] == all_channels: data["all_sales_channels"] = True diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index 9f31013ac..770498bd9 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -200,6 +200,11 @@ class UpdateView(APIView): device.save() device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) + from ...base.signals import device_info_updated + device_info_updated.send( + sender=Device, old_device=request.auth, new_device=device + ) + serializer = DeviceSerializer(device) return Response(serializer.data) diff --git a/src/pretix/base/auth.py b/src/pretix/base/auth.py index fbdb1415a..297905a39 100644 --- a/src/pretix/base/auth.py +++ b/src/pretix/base/auth.py @@ -32,13 +32,16 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. +import string from collections import OrderedDict from importlib import import_module from django import forms from django.conf import settings from django.contrib.auth import authenticate -from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.hashers import check_password, make_password +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _, ngettext def get_auth_backends(): @@ -160,3 +163,62 @@ class NativeAuthBackend(BaseAuthBackend): u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password']) if u and u.auth_backend == self.identifier: return u + + +class NumericAndAlphabeticPasswordValidator: + + def validate(self, password, user=None): + has_numeric = any(c in string.digits for c in password) + has_alpha = any(c in string.ascii_letters for c in password) + if not has_numeric or not has_alpha: + raise ValidationError( + _( + "Your password must contain both numeric and alphabetic characters.", + ), + code="password_numeric_and_alphabetic", + ) + + def get_help_text(self): + return _( + "Your password must contain both numeric and alphabetic characters.", + ) + + +class HistoryPasswordValidator: + + def __init__(self, history_length=4): + self.history_length = history_length + + def validate(self, password, user=None): + from pretix.base.models import User + + if not user or not user.pk or not isinstance(user, User): + return + + for hp in user.historic_passwords.order_by("-created")[:self.history_length]: + if check_password(password, hp.password): + raise ValidationError( + ngettext( + "Your password may not be the same as your previous password.", + "Your password may not be the same as one of your %(history_length)s previous passwords.", + self.history_length, + ), + code="password_history", + params={"history_length": self.history_length}, + ) + + def get_help_text(self): + return ngettext( + "Your password may not be the same as your previous password.", + "Your password may not be the same as one of your %(history_length)s previous passwords.", + self.history_length, + ) % {"history_length": self.history_length} + + def password_changed(self, password, user=None): + if not user: + pass + + user.historic_passwords.create(password=make_password(password)) + user.historic_passwords.filter( + pk__in=user.historic_passwords.order_by("-created")[self.history_length:].values_list("pk", flat=True), + ).delete() diff --git a/src/pretix/base/customersso/oidc.py b/src/pretix/base/customersso/oidc.py index cf733de90..cb3a6d20d 100644 --- a/src/pretix/base/customersso/oidc.py +++ b/src/pretix/base/customersso/oidc.py @@ -46,6 +46,8 @@ This module contains utilities for implementing OpenID Connect for customer auth as well as an OpenID Provider (OP). """ +pretix_token_endpoint_auth_methods = ['client_secret_basic', 'client_secret_post'] + def _urljoin(base, path): if not base.endswith("/"): @@ -127,6 +129,16 @@ def oidc_validate_and_complete_config(config): fields=", ".join(provider_config.get("claims_supported", [])) )) + if "token_endpoint_auth_methods_supported" in provider_config: + token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported", + ["client_secret_basic"]) + if not any(x in pretix_token_endpoint_auth_methods for x in token_endpoint_auth_methods_supported): + raise ValidationError( + _(f'No supported Token Endpoint Auth Methods supported: {token_endpoint_auth_methods_supported}').format( + token_endpoint_auth_methods_supported=", ".join(token_endpoint_auth_methods_supported) + ) + ) + config['provider_config'] = provider_config return config @@ -147,6 +159,18 @@ def oidc_authorize_url(provider, state, redirect_uri): def oidc_validate_authorization(provider, code, redirect_uri): endpoint = provider.configuration['provider_config']['token_endpoint'] + + # Wall of shame and RFC ignorant IDPs + if endpoint == 'https://www.linkedin.com/oauth/v2/accessToken': + token_endpoint_auth_method = 'client_secret_post' + else: + token_endpoint_auth_methods = provider.configuration['provider_config'].get( + 'token_endpoint_auth_methods_supported', ['client_secret_basic'] + ) + token_endpoint_auth_method = [ + x for x in pretix_token_endpoint_auth_methods if x in token_endpoint_auth_methods + ][0] + params = { # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 # https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint @@ -154,6 +178,11 @@ def oidc_validate_authorization(provider, code, redirect_uri): 'code': code, 'redirect_uri': redirect_uri, } + + if token_endpoint_auth_method == 'client_secret_post': + params['client_id'] = provider.configuration['client_id'] + params['client_secret'] = provider.configuration['client_secret'] + try: resp = requests.post( endpoint, @@ -161,7 +190,10 @@ def oidc_validate_authorization(provider, code, redirect_uri): headers={ 'Accept': 'application/json', }, - auth=(provider.configuration['client_id'], provider.configuration['client_secret']), + auth=( + provider.configuration['client_id'], + provider.configuration['client_secret'] + ) if token_endpoint_auth_method == 'client_secret_basic' else None, ) resp.raise_for_status() data = resp.json() diff --git a/src/pretix/base/management/commands/runperiodic.py b/src/pretix/base/management/commands/runperiodic.py index 6467efc05..a6ff0b035 100644 --- a/src/pretix/base/management/commands/runperiodic.py +++ b/src/pretix/base/management/commands/runperiodic.py @@ -50,6 +50,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--tasks', action='store', type=str, help='Only execute the tasks with this name ' '(dotted path, comma separation)') + parser.add_argument('--list-tasks', action='store_true', help='Only list all tasks') parser.add_argument('--exclude', action='store', type=str, help='Exclude the tasks with this name ' '(dotted path, comma separation)') @@ -61,6 +62,9 @@ class Command(BaseCommand): for receiver in periodic_task._live_receivers(self): name = f'{receiver.__module__}.{receiver.__name__}' + if options['list_tasks']: + print(name) + continue if options.get('tasks'): if name not in options.get('tasks').split(','): continue diff --git a/src/pretix/base/migrations/0270_historicpassword.py b/src/pretix/base/migrations/0270_historicpassword.py new file mode 100644 index 000000000..6a42e15c7 --- /dev/null +++ b/src/pretix/base/migrations/0270_historicpassword.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.15 on 2024-09-16 15:10 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0269_order_api_meta"), + ] + + operations = [ + migrations.CreateModel( + name="HistoricPassword", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("password", models.CharField(max_length=128)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="historic_passwords", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index 7f2e38017..d15396e95 100644 --- a/src/pretix/base/modelimport.py +++ b/src/pretix/base/modelimport.py @@ -213,7 +213,13 @@ class DatetimeColumnMixin: except (ValueError, TypeError): pass else: - raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value)) + try: + d = datetime.datetime.fromisoformat(value) + if not d.tzinfo: + d = d.replace(tzinfo=self.timezone) + return d + except (ValueError, TypeError): + raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value)) class DecimalColumnMixin: diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index 0bda26fd4..6d9ef2e29 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -40,8 +40,8 @@ from phonenumbers import SUPPORTED_REGIONS from pretix.base.forms.questions import guess_country from pretix.base.modelimport import ( - DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin, - i18n_flat, + BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, + SubeventColumnMixin, i18n_flat, ) from pretix.base.models import ( Customer, ItemVariation, OrderPosition, Question, QuestionAnswer, @@ -604,6 +604,22 @@ class Comment(ImportColumn): order.comment = value or '' +class CheckinAttentionColumn(BooleanColumnMixin, ImportColumn): + identifier = 'checkin_attention' + verbose_name = gettext_lazy('Requires special attention') + + def assign(self, value, order, position, invoice_address, **kwargs): + order.checkin_attention = value + + +class CheckinTextColumn(ImportColumn): + identifier = 'checkin_text' + verbose_name = gettext_lazy('Check-in text') + + def assign(self, value, order, position, invoice_address, **kwargs): + order.checkin_text = value + + class QuestionColumn(ImportColumn): def __init__(self, event, q): self.q = q @@ -742,6 +758,8 @@ def get_order_import_columns(event): ValidUntil(event), Locale(event), Saleschannel(event), + CheckinAttentionColumn(event), + CheckinTextColumn(event), Expires(event), Comment(event), ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 3903db3b0..3dbe4874f 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -571,13 +571,23 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): def get_session_auth_hash(self): """ - Return an HMAC that needs to + Return an HMAC that needs to be the same throughout the session, used e.g. for forced + logout after every password change. + """ + return self._get_session_auth_hash(secret=settings.SECRET_KEY) + + def get_session_auth_fallback_hash(self): + for fallback_secret in settings.SECRET_KEY_FALLBACKS: + yield self._get_session_auth_hash(secret=fallback_secret) + + def _get_session_auth_hash(self, secret): + """ """ key_salt = "pretix.base.models.User.get_session_auth_hash" payload = self.password payload += self.email payload += self.session_token - return salted_hmac(key_salt, payload).hexdigest() + return salted_hmac(key_salt, payload, secret=secret).hexdigest() def update_session_token(self): self.session_token = generate_session_token() @@ -654,3 +664,9 @@ class WebAuthnDevice(Device): @property def webauthnpubkey(self): return websafe_decode(self.pub_key) + + +class HistoricPassword(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="historic_passwords") + created = models.DateTimeField(auto_now_add=True) + password = models.CharField(verbose_name=_("Password"), max_length=128) diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py index 6904fe569..b93acdebb 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -219,13 +219,24 @@ class Customer(LoggedModel): return is_password_usable(self.password) def get_session_auth_hash(self): + """ + Return an HMAC that needs to be the same throughout the session, used e.g. for forced + logout after every password change. + """ + return self._get_session_auth_hash(secret=settings.SECRET_KEY) + + def get_session_auth_fallback_hash(self): + for fallback_secret in settings.SECRET_KEY_FALLBACKS: + yield self._get_session_auth_hash(secret=fallback_secret) + + def _get_session_auth_hash(self, secret): """ Return an HMAC of the password field. """ key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash" payload = self.password payload += self.email - return salted_hmac(key_salt, payload).hexdigest() + return salted_hmac(key_salt, payload, secret=secret).hexdigest() def get_email_context(self): from pretix.base.settings import get_name_parts_localized diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index fadf1c972..ce9a6dbc9 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -40,6 +40,7 @@ import json import logging import operator import string +import warnings from collections import Counter from datetime import datetime, time, timedelta from decimal import Decimal @@ -381,8 +382,28 @@ class Order(LockModel, LoggedModel): self.event.cache.delete('complain_testmode_orders') self.delete() + def email_confirm_secret(self): + return self.tagged_secret("email_confirm", 9) + def email_confirm_hash(self): - return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9] + warnings.warn('Use email_confirm_secret() instead of email_confirm_hash().', + DeprecationWarning) + return self.email_confirm_secret() + + def check_email_confirm_secret(self, received_secret): + return ( + hmac.compare_digest( + self.tagged_secret("email_confirm", 9), + received_secret[:9].lower() + ) or any( + # TODO: remove this clause after a while (compatibility with old secrets currently in flight) + hmac.compare_digest( + hashlib.sha256(sk.encode() + self.secret.encode()).hexdigest()[:9], + received_secret + ) + for sk in [settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS] + ) + ) def get_extended_status_display(self): # Changes in this method should to be replicated in pretixcontrol/orders/fragment_order_status.html @@ -2835,6 +2856,14 @@ class OrderPosition(AbstractPosition): (self.order.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists()) ) + @property + def code(self): + """ + A ticket code which is unique among all events of a single organizer, + built by the order code and the position number. + """ + return '{order_code}-{position}'.format(order_code=self.order.code, position=self.positionid) + class Transaction(models.Model): """ diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index 7964a9bb3..d496e41cb 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -29,6 +29,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.deconstruct import deconstructible from django.utils.formats import localize +from django.utils.functional import lazy +from django.utils.html import format_html from django.utils.translation import gettext_lazy as _, pgettext from i18nfield.fields import I18nCharField from i18nfield.strings import LazyI18nString @@ -120,6 +122,8 @@ EU_CURRENCIES = { } VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'} +format_html_lazy = lazy(format_html, str) + def is_eu_country(cc): cc = str(cc) @@ -193,11 +197,17 @@ class TaxRule(LoggedModel): eu_reverse_charge = models.BooleanField( verbose_name=_("Use EU reverse charge taxation rules"), default=False, - help_text=_("Not recommended. Most events will NOT be qualified for reverse charge since the place of " - "taxation is the location of the event. This option disables charging VAT for all customers " - "outside the EU and for business customers in different EU countries who entered a valid EU VAT " - "ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax " - "calculation. USE AT YOUR OWN RISK.") + help_text=format_html_lazy( + '{} {}', + _('This feature will be removed in the future as it does not handle VAT for non-business customers in ' + 'other EU countries in a way that works for all organizers. Use custom rules instead.'), + _('DEPRECATED'), + _("Not recommended. Most events will NOT be qualified for reverse charge since the place of " + "taxation is the location of the event. This option disables charging VAT for all customers " + "outside the EU and for business customers in different EU countries who entered a valid EU VAT " + "ID. Only enable this option after consulting a tax counsel. No warranty given for correct tax " + "calculation. USE AT YOUR OWN RISK.") + ), ) home_country = FastCountryField( verbose_name=_('Merchant country'), @@ -296,8 +306,11 @@ class TaxRule(LoggedModel): if rate == Decimal('0.00'): return TaxedPrice( - net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'), - rate=rate, name=self.name + net=max(Decimal('0.00'), base_price - subtract_from_gross), + gross=max(Decimal('0.00'), base_price - subtract_from_gross), + tax=Decimal('0.00'), + rate=rate, + name=self.name, ) if base_price_is == 'auto': diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 7028add1f..9b5731ec5 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -1182,10 +1182,11 @@ def process_exit_all(sender, **kwargs): positions = cl.positions_inside_query(ignore_status=True, at_time=cl.exit_all_at) for p in positions: with scope(organizer=cl.event.organizer): - ci = Checkin.objects.create( + ci, created = Checkin.objects.get_or_create( position=p, list=cl, auto_checked_in=True, type=Checkin.TYPE_EXIT, datetime=cl.exit_all_at ) - checkin_created.send(cl.event, checkin=ci) + if created: + checkin_created.send(cl.event, checkin=ci) d = cl.exit_all_at.astimezone(cl.event.timezone) if cl.event.settings.get(f'autocheckin_dst_hack_{cl.pk}'): # move time back if yesterday was DST switch d -= timedelta(hours=1) diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index ae5387ebc..9d30d927a 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -301,7 +301,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La order.event, 'presale:event.order.open', kwargs={ 'order': order.code, 'secret': order.secret, - 'hash': order.email_confirm_hash() + 'hash': order.email_confirm_secret() } ) ) diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py index 8ab1a42e6..65d4530b7 100644 --- a/src/pretix/base/services/placeholders.py +++ b/src/pretix/base/services/placeholders.py @@ -262,7 +262,7 @@ def base_placeholders(sender, **kwargs): 'presale:event.order.open', kwargs={ 'order': order.code, 'secret': order.secret, - 'hash': order.email_confirm_hash() + 'hash': order.email_confirm_secret() } ), lambda event: build_absolute_uri( event, @@ -443,7 +443,7 @@ def base_placeholders(sender, **kwargs): 'organizer': event.organizer.slug, 'order': order.code, 'secret': order.secret, - 'hash': order.email_confirm_hash(), + 'hash': order.email_confirm_secret(), }), ) for order in orders diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index aa78157fb..ff207cf20 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -886,3 +886,12 @@ is given as the first argument. The ``sender`` keyword argument will contain the organizer. """ + +device_info_updated = django.dispatch.Signal() +""" +Arguments: ``old_device``, ``new_device`` + +This signal is sent out each time the information for a Device is modified. +Both the original and updated versions of the Device are included to allow +receivers to see what has been updated. +""" diff --git a/src/pretix/base/templates/pretixbase/email/order_details.html b/src/pretix/base/templates/pretixbase/email/order_details.html index 6511e15ed..2ec01146e 100644 --- a/src/pretix/base/templates/pretixbase/email/order_details.html +++ b/src/pretix/base/templates/pretixbase/email/order_details.html @@ -143,7 +143,7 @@
- + {% trans "View order details" %}
diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py index 10006402b..a056ead5f 100644 --- a/src/pretix/base/timeline.py +++ b/src/pretix/base/timeline.py @@ -103,7 +103,7 @@ def timeline_for_event(event, subevent=None): tl.append(TimelineEvent( event=event, subevent=subevent, datetime=rd.datetime(ev), - description=pgettext_lazy('timeline', 'Customers can no longer modify their orders'), + description=pgettext_lazy('timeline', 'Customers can no longer modify their order information'), edit_url=ev_edit_url )) @@ -159,6 +159,18 @@ def timeline_for_event(event, subevent=None): }) )) + rd = event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper) + if rd and event.settings.change_allow_user_until: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'Customers can no longer make changes to their orders'), + edit_url=reverse('control:event.settings.cancel', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper) if rd and event.settings.waiting_list_enabled: tl.append(TimelineEvent( diff --git a/src/pretix/base/views/tasks.py b/src/pretix/base/views/tasks.py index bed6862bb..8a9e6e909 100644 --- a/src/pretix/base/views/tasks.py +++ b/src/pretix/base/views/tasks.py @@ -30,7 +30,9 @@ from celery import states from celery.result import AsyncResult from django.conf import settings from django.contrib import messages -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import ( + BadRequest, PermissionDenied, ValidationError, +) from django.core.files.uploadedfile import UploadedFile from django.db import transaction from django.http import HttpResponse, JsonResponse, QueryDict @@ -131,6 +133,8 @@ class AsyncMixin: return data def get_result(self, request): + if not request.GET.get('async_id'): + raise BadRequest("No async_id given") res = AsyncResult(request.GET.get('async_id')) if 'ajax' in self.request.GET: return JsonResponse(self._return_ajax_result(res, timeout=0.25)) @@ -140,7 +144,12 @@ class AsyncMixin: return self.success(res.info) else: return self.error(res.info) - return render(request, 'pretixpresale/waiting.html') + state, info = res.state, res.info + return render(request, 'pretixpresale/waiting.html', { + 'started': state in ('PROGRESS', 'STARTED'), + 'percentage': info.get('value', 0) if isinstance(info, dict) else 0, + 'steps': info.get('steps', []) if isinstance(info, dict) else None, + }) def success(self, value): smes = self.get_success_message(value) @@ -208,6 +217,8 @@ class AsyncAction(AsyncMixin): def get(self, request, *args, **kwargs): if 'async_id' in request.GET and settings.HAS_CELERY: + if not request.GET.get('async_id'): + raise BadRequest("No async_id given") return self.get_result(request) return self.http_method_not_allowed(request) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 584660b52..a7f59a943 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1143,12 +1143,12 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): widget=I18nTextInput, ) mail_subject_order_incomplete_payment = I18nFormField( - label=_("Subject"), + label=_("Subject (if an incomplete payment was received)"), required=False, widget=I18nTextInput, ) mail_text_order_incomplete_payment = I18nFormField( - label=_("Text"), + label=_("Text (if an incomplete payment was received)"), required=False, widget=I18nMarkdownTextarea, help_text=_("This email only applies to payment methods that can receive incomplete payments, " diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index c45fc9e2e..3b7498594 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -239,11 +239,14 @@ class VoucherForm(I18nModelForm): self.instance.event, self.instance.quota, self.instance.item, self.instance.variation ) Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk) - if 'seat' in self.fields and data.get('seat'): - self.instance.seat = Voucher.clean_seat_id( - data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk - ) - self.instance.item = self.instance.seat.product + if 'seat' in self.fields: + if data.get('seat'): + self.instance.seat = Voucher.clean_seat_id( + data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk + ) + self.instance.item = self.instance.seat.product + else: + self.instance.seat = None voucher_form_validation.send(sender=self.instance.event, form=self, data=data) diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html index ddf4b9591..47130a6b0 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html @@ -41,7 +41,7 @@ {% bootstrap_field form.eu_reverse_charge layout="control" %} {% bootstrap_field form.home_country layout="control" %} {% bootstrap_field form.keep_gross_if_rate_changes layout="control" %} -

{% trans "Custom taxation rules" %}

+

{% trans "Custom rules" %}

{% blocktrans trimmed %} These settings are intended for professional users with very specific taxation situations. diff --git a/src/pretix/control/templates/pretixcontrol/items/index.html b/src/pretix/control/templates/pretixcontrol/items/index.html index 52bc17f01..4bda32aee 100644 --- a/src/pretix/control/templates/pretixcontrol/items/index.html +++ b/src/pretix/control/templates/pretixcontrol/items/index.html @@ -133,7 +133,7 @@ {% endif %} {{ i.default_price|money:request.event.currency }} {% if i.original_price %}{{ i.original_price|money:request.event.currency }}{% endif %} - {% if i.tax_rule and i.default_price %} + {% if i.tax_rule %}
{% if not i.tax_rule.price_includes_tax %} diff --git a/src/pretix/control/templates/pretixcontrol/waitinglist/index.html b/src/pretix/control/templates/pretixcontrol/waitinglist/index.html index 4c332d1e9..cdd4e39f9 100644 --- a/src/pretix/control/templates/pretixcontrol/waitinglist/index.html +++ b/src/pretix/control/templates/pretixcontrol/waitinglist/index.html @@ -21,6 +21,11 @@ {% trans "The waiting list is no longer active for this event. The waiting list no longer affects quotas and no longer notifies waiting users." %}
{% endif %} + {% if request.event.settings.hide_sold_out %} +
+ {% trans "According to your event settings, sold out products are hidden from customers. This way, customers will not be able to discovere the waiting list." %} +
+ {% endif %}
{% if 'can_change_orders' in request.eventpermset %}