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 @@
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 %}