Compare commits

..
Author SHA1 Message Date
Mira Weller 3442930a99 Fix migration conflict 2026-07-16 16:06:08 +02:00
60 changed files with 415 additions and 792 deletions
+3 -3
View File
@@ -40,11 +40,11 @@ dependencies = [
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-countries==8.2.*",
"django-filter==26.1",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
"django-hierarkey==2.0.*,>=2.0.2",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
"django-i18nfield==1.11.*",
"django-libsass==0.9",
@@ -94,7 +94,7 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.66.*",
"sentry-sdk==2.65.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
-3
View File
@@ -108,7 +108,6 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
@@ -147,7 +146,6 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
)
@@ -184,7 +182,6 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('GET', 'api-v1:checkinrpc.search'),
)
+1 -3
View File
@@ -1658,7 +1658,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
count_waitinglist=False,
force=request.data.get('force', False),
send_mail=send_mail,
ignore_date=request.data.get('force', False),
)
except Quota.QuotaExceededException:
pass
@@ -1694,8 +1693,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
auth=self.request.auth,
count_waitinglist=False,
send_mail=send_mail,
force=force,
ignore_date=force)
force=force)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
+9 -12
View File
@@ -23,7 +23,6 @@ import sys
from django.conf import settings
from django.urls import reverse
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
@@ -36,23 +35,21 @@ def get_powered_by(request, safelink=True):
d = gs.settings.license_check_input
if d.get('poweredby_name'):
if d.get('poweredby_url'):
msg = format_html(
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
name=d['poweredby_name'],
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']),
)),
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
),
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
))
)
)
else:
msg = format_html(
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
name=d['poweredby_name'],
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
a_attr='href="{}" target="_blank" rel="noopener"'.format(
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
))
)
)
else:
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
+13 -4
View File
@@ -19,6 +19,7 @@
# 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/>.
#
import ipaddress
import logging
import smtplib
import socket
@@ -42,7 +43,6 @@ from pretix.base.templatetags.rich_text import (
markdown_compile_email, truelink_callback,
)
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.helpers.ssrf import should_block_access
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -57,6 +57,8 @@ logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend)
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try:
@@ -252,9 +254,16 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
is_private, msg = should_block_access(sa)
if is_private:
raise socket.error(msg)
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
if check_ip4 in _cgnat_net:
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
try:
+32 -9
View File
@@ -33,6 +33,8 @@
# 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 hashlib
import ipaddress
import logging
from django import forms
@@ -40,12 +42,13 @@ from django.conf import settings
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.metrics import pretix_failed_logins
from pretix.base.models import User
from pretix.helpers.dicts import move_to_end
from pretix.helpers.ratelimit import rate_limit
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
@@ -82,20 +85,40 @@ class LoginForm(forms.Form):
else:
move_to_end(self.fields, 'keep_logged_in')
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300)
if rate_limit("login", **rate_limit_kwargs, increase=False):
# Check rate limit without counting up, we increase below only on failed logins
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.get(self.ratelimit_key)
if cnt and int(cnt) > 10:
pretix_failed_logins.inc(1, reason="ratelimit")
logger.info("Backend login rejected due to rate limit.")
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if self.user_cache is None:
if self.ratelimit_key:
rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 300)
logger.info("Backend login invalid.")
pretix_failed_logins.inc(1, reason="invalid")
# Count towards rate limit (result is ignored, we are checking above)
rate_limit("login", **rate_limit_kwargs)
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
+1 -1
View File
@@ -959,7 +959,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required,
help_text=help_text,
initial=_initial,
widget=TimePickerWidget(without_seconds=True),
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
if not help_text:
+11 -22
View File
@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
@@ -45,7 +46,6 @@ from pytz import common_timezones
from pretix.base.models import User
from pretix.control.forms import SingleLanguageWidget
from pretix.helpers.format import format_map
from pretix.helpers.ratelimit import rate_limit
class UserSettingsForm(forms.ModelForm):
@@ -128,11 +128,16 @@ class UserPasswordChangeForm(forms.Form):
def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw')
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.user.password):
raise forms.ValidationError(
@@ -170,35 +175,19 @@ class UserEmailChangeForm(forms.Form):
error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. "
"Please choose a different one."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
new_email = forms.EmailField(label=_('New email address'))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.request = kwargs.pop('request')
super().__init__(*args, **kwargs)
def clean_new_email(self):
email = self.cleaned_data['new_email']
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
# Rate limit lookup for conflicting email addresses to make enumeration harder
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
raise forms.ValidationError(
self.error_messages['duplicate_identifier'],
code='duplicate_identifier',
)
if rate_limit("emailchange", self.user.pk, max_num=2, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return email
+5 -50
View File
@@ -43,10 +43,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString
from pretix.helpers.i18n import (
get_format_without_seconds, get_javascript_format,
get_javascript_format_without_seconds,
)
def replace_arabic_numbers(inp):
@@ -112,7 +108,7 @@ class DatePickerWidget(forms.DateInput):
class TimePickerWidget(forms.TimeInput):
def __init__(self, attrs=None, time_format=None, without_seconds=False):
def __init__(self, attrs=None, time_format=None):
attrs = attrs or {}
if 'placeholder' in attrs:
del attrs['placeholder']
@@ -121,27 +117,8 @@ class TimePickerWidget(forms.TimeInput):
time_attrs['class'] += ' timepickerfield'
time_attrs['autocomplete'] = 'off'
if time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def placeholder():
if without_seconds:
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
@@ -205,7 +182,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False):
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
attrs = attrs or {}
if 'placeholder' in attrs:
del attrs['placeholder']
@@ -228,36 +205,14 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
).isoformat()
if date_format or time_format or without_seconds:
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
def date_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(date_format or "DATE_INPUT_FORMATS")
return get_javascript_format(date_format or "DATE_INPUT_FORMATS")
date_attrs['data-format'] = lazy(date_format_attr, str)
def time_format_attr():
if without_seconds:
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
time_attrs['data-format'] = lazy(time_format_attr, str)
def date_placeholder():
if without_seconds:
df = date_format or get_format_without_seconds('DATE_INPUT_FORMATS')
else:
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
def time_placeholder():
if without_seconds:
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
else:
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
+18 -94
View File
@@ -19,8 +19,6 @@
# 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/>.
#
import base64
import hashlib
import logging
import re
from collections import OrderedDict
@@ -71,44 +69,11 @@ def get_supported_language(requested_language, allowed_languages, default_langua
return language
class BaseLocaleMiddleware(MiddlewareMixin):
"""
This is a reduced LocaleMiddleware that uses only information contained in the WSGI request data
to figure out the language (cookie and browser settings). We need it to have a consistent language
for error pages that are generated from the middleware stack before we know e.g. which user is logged
in or which event is selected.
"""
def process_request(self, request: HttpRequest):
language = get_language_from_early_request(request)
translation.activate(language)
set_region(None)
request.LANGUAGE_CODE = language
timezone.deactivate()
def process_response(self, request: HttpRequest, response: HttpResponse):
language = translation.get_language()
patch_vary_headers(response, ('Accept-Language',))
if 'Content-Language' not in response:
response['Content-Language'] = language
return response
class LocaleMiddleware(MiddlewareMixin):
"""
This is the full LocaleMiddleware that uses all available information to figure out the correct
language for the request using all available sources, in this order of priority:
- Backend: User settings
- Language cookie
- Frontend: Customer account settings
- Browser settings
- Frontend: Event/Organizer settings
- System default
It needs to run late in the middleware stack to have all information available for these steps.
For some cases, it is even ran a second time since the event is sometimes only figured out after the
middleware stack (can happen for plugin views).
This middleware sets the correct locale and timezone
for a request.
"""
def process_request(self, request: HttpRequest):
@@ -223,24 +188,6 @@ def get_default_language():
return settings.LANGUAGE_CODE
def get_language_from_early_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
show using only WSGI-available information. Only languages listed in
settings.LANGUAGES are taken into account. If the user requests a sublanguage
where we have a main language, we send out the main language.
"""
global _supported
if _supported is None:
_supported = OrderedDict(settings.LANGUAGES)
return (
get_language_from_cookie(request)
or get_language_from_browser(request)
or get_default_language()
)
def get_language_from_request(request: HttpRequest) -> str:
"""
Analyzes the request to find what language the user wants the system to
@@ -255,6 +202,7 @@ def get_language_from_request(request: HttpRequest) -> str:
if request.path.startswith(get_script_prefix() + 'control'):
return (
get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
@@ -314,28 +262,7 @@ def _merge_csp(a, b):
for k, v in a.items():
if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha256-")]
def add_to_response_csp(response, csp_to_merge):
if "Content-Security-Policy" in response:
csp = _parse_csp(response["Content-Security-Policy"])
else:
csp = {}
_merge_csp(csp, csp_to_merge)
if csp:
response["Content-Security-Policy"] = _render_csp(csp)
def add_to_response_csp_via_request(request, csp_to_merge):
_merge_csp(request._csp_to_merge, csp_to_merge)
def calculate_csp_hash(data):
hash_str = base64.b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode("ascii")
return f"'sha256-{hash_str}'"
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
class SecurityMiddleware(MiddlewareMixin):
@@ -355,9 +282,6 @@ class SecurityMiddleware(MiddlewareMixin):
# but at the moment it does not seem to be an issue to just send it.
)
def process_request(self, request):
request._csp_to_merge = {}
def process_response(self, request, resp):
if settings.DEBUG and resp.status_code >= 400:
# Don't use CSP on debug error page as it breaks of Django's fancy error
@@ -369,22 +293,18 @@ class SecurityMiddleware(MiddlewareMixin):
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
if self._needs_csp(request, resp):
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES:
if 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
return resp
if not getattr(resp, '_csp_ignore', False):
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
return resp
def _needs_csp(self, request, resp):
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES:
return False
if getattr(resp, '_csp_ignore', False):
return False
return True
def _build_csp(self, request, resp):
url = resolve(request.path_info)
@@ -417,6 +337,13 @@ class SecurityMiddleware(MiddlewareMixin):
h['style-src'] += ["'unsafe-inline'"]
h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"]
if hasattr(request, 'csp_nonce'):
nonce = f"'nonce-{request.csp_nonce}'"
h['script-src'].append(nonce)
if not settings.VITE_DEV_MODE:
# can't have 'unsafe-inline' and nonce at the same time
h['style-src'].append(nonce)
# Only include pay.google.com for wallet detection purposes on the Payment selection page
if (
url.url_name == "event.order.pay.change" or
@@ -429,9 +356,6 @@ class SecurityMiddleware(MiddlewareMixin):
if settings.LOG_CSP:
h['report-uri'] = ["/csp_report/"]
if request._csp_to_merge:
_merge_csp(h, request._csp_to_merge)
if 'Content-Security-Policy' in resp:
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
-9
View File
@@ -179,12 +179,6 @@ class EventMixin:
self.date_to.astimezone(tz), ("D" if short else "l")
)
def is_same_day(self):
if not self.date_to:
return True
else:
return self.date_from.astimezone(self.timezone).date() == self.date_to.astimezone(self.timezone).date()
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False, try_to_show_times=False) -> str:
"""
Returns a formatted string containing the start date and the end date
@@ -238,9 +232,6 @@ class EventMixin:
@property
def timezone(self):
# If we get rid of the shim, verify that
# https://github.com/py-vobject/vobject/issues/117#issuecomment-5045645314
# has been released and included
return pytz_deprecation_shim.timezone(self.settings.timezone)
@property
+5 -8
View File
@@ -2286,12 +2286,9 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs)
def ActivePositionManager(**scope):
class InnerClass(ScopedManager(**scope).__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
return InnerClass()
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
class OrderFee(RoundingCorrectionMixin, models.Model):
@@ -2381,7 +2378,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@property
def net_value(self):
@@ -2600,7 +2597,7 @@ class OrderPosition(AbstractPosition):
)
all = ScopedManager(organizer='organizer')
objects = ActivePositionManager(organizer='organizer')
objects = ActivePositionManager()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
-3
View File
@@ -24,7 +24,6 @@ from typing import List, Optional
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
@@ -97,8 +96,6 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
"""
if lock and not transaction.get_connection().in_atomic_block:
raise Exception('validate_memberships_in_order(lock=True) should only be called in atomic transaction!')
tz = event.timezone
applicable_positions = [
p for p in positions
-6
View File
@@ -791,12 +791,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
[op.seat for op in sorted_positions if op.seat],
shared_lock_objects=[event]
)
elif any(cp.voucher and cp.voucher.budget for cp in sorted_positions):
# Voucher budgets are not guaranteed by the cart manager
lock_objects(
[op.voucher for op in sorted_positions if op.voucher and op.voucher.budget],
shared_lock_objects=[event]
)
q_avail = Counter()
v_avail = Counter()
+6 -14
View File
@@ -19,10 +19,12 @@
# 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/>.
#
import json
import logging
import pathlib
import re
import secrets
from urllib.parse import urljoin
from urllib.request import urlopen
@@ -31,10 +33,6 @@ from django import template
from django.conf import settings
from django.utils.safestring import mark_safe
from pretix.base.middleware import (
add_to_response_csp_via_request, calculate_csp_hash,
)
register = template.Library()
LOGGER = logging.getLogger(__name__)
_MANIFEST = {}
@@ -236,16 +234,10 @@ def vite_importmap(context):
if not imports:
return ""
json_string = json.dumps({"imports": imports})
# Calculate hash and add it to the CSP info in the request, so that will be merged into the
# response header later on by the middleware
# Generate a nonce and store it on the request so the CSP middleware can allow it
nonce = secrets.token_urlsafe(16)
request = context.get('request')
if request:
csp_hash = calculate_csp_hash(json_string)
add_to_response_csp_via_request(request, {
'style-src': [csp_hash],
'script-src': [csp_hash],
})
request.csp_nonce = nonce
return f'<script type="importmap">{json_string}</script>'
return f'<script type="importmap" nonce="{nonce}">{json.dumps({"imports": imports})}</script>'
+9 -9
View File
@@ -197,10 +197,10 @@ class EventWizardBasicsForm(I18nModelForm):
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}),
'slug': SlugWidget,
}
@@ -521,11 +521,11 @@ class EventUpdateForm(I18nModelForm):
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
}
+2 -91
View File
@@ -39,7 +39,6 @@ from urllib.parse import urlencode
from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import (
Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet,
)
@@ -60,7 +59,7 @@ from pretix.base.models import (
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
User, Voucher,
Voucher,
)
from pretix.base.signals import register_payment_providers
from pretix.base.timeframes import (
@@ -771,7 +770,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
)
elif q.type == Question.TYPE_TIME:
self.fields[fname] = forms.TimeField(
widget=TimePickerWidget(without_seconds=True),
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
help_text=_('Exact matches only'),
**kwargs,
)
@@ -3008,91 +3007,3 @@ class OutgoingMailFilterForm(FilterForm):
qs = qs.order_by("-created", "-pk")
return qs
class LogFilterForm(FilterForm):
source = forms.ChoiceField(
label=_('Source'),
choices=[
('', _('All sources')),
('team', _('Team actions')),
('customer', _('Customer actions')),
('device', _('Device actions')),
],
required=False
)
device = SafeModelChoiceField(
label=_('Device'),
empty_label=_('All devices'),
queryset=Device.objects.none(),
required=False
)
user_email = forms.EmailField(
label=_('User email'),
widget=forms.EmailInput(
attrs={"placeholder": _('All users')}
),
required=False
)
action_type = forms.CharField(
widget=forms.HiddenInput,
required=False,
)
content_type = forms.ModelChoiceField(
queryset=ContentType.objects.all(),
widget=forms.HiddenInput,
required=False,
)
object = forms.IntegerField(
widget=forms.HiddenInput,
required=False
)
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields['device'].queryset = self.organizer.devices.all().order_by('device_id')
self.fields['device'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.devices.select2', kwargs={
'organizer': self.organizer.slug,
}),
'data-placeholder': _('All devices'),
}
)
self.fields['device'].widget.choices = self.fields['device'].choices
self.fields['device'].label = _('Device')
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('source') == 'team':
qs = qs.filter(user__isnull=False)
elif fdata.get('source') == 'device':
qs = qs.filter(device__isnull=False)
elif fdata.get('source') == 'customer':
qs = qs.filter(user__isnull=True, device__isnull=True)
if fdata.get('device'):
qs = qs.filter(device_id=fdata['device'].pk)
if fdata.get('action_type'):
qs = qs.filter(action_type__in=fdata['action_type'].split(','))
if fdata.get('user_email'):
try:
user = User.objects.get(email=fdata['user_email'].lower())
qs = qs.filter(user=user)
except User.DoesNotExist:
# Do not actively leak info that this user does not exist. Yes, this will likely have a
# timing attack possibility, but with the magic that database query planners do,
# it's probably impossible to avoid that.
qs = qs.none()
if fdata.get('content_type'):
qs = qs.filter(content_type=fdata.get('content_type'))
if fdata.get('object'):
qs = qs.filter(object_id=fdata.get('object'))
return qs
+4 -4
View File
@@ -246,8 +246,8 @@ class QuestionForm(I18nModelForm):
'valid_string_length_max',
]
widgets = {
'valid_datetime_min': SplitDateTimePickerWidget(without_seconds=True),
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
'valid_datetime_min': SplitDateTimePickerWidget(),
'valid_datetime_max': SplitDateTimePickerWidget(),
'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple(
@@ -1427,6 +1427,6 @@ class ItemProgramTimeForm(I18nModelForm):
'end': forms.SplitDateTimeField,
}
widgets = {
'start': SplitDateTimePickerWidget(without_seconds=True),
'end': SplitDateTimePickerWidget(without_seconds=True),
'start': SplitDateTimePickerWidget(),
'end': SplitDateTimePickerWidget(),
}
+6 -13
View File
@@ -39,7 +39,6 @@ from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm
from pretix.helpers.i18n import get_javascript_format_without_seconds
from pretix.helpers.money import change_decimal_field
@@ -81,11 +80,11 @@ class SubEventForm(I18nModelForm):
'presale_end': SplitDateTimeField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(without_seconds=True),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
'date_from': SplitDateTimePickerWidget(),
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
}
@@ -163,7 +162,7 @@ class SubEventBulkEditForm(I18nModelForm):
self.fields[k + '_time'] = forms.TimeField(
label=self._meta.model._meta.get_field(k).verbose_name,
help_text=self._meta.model._meta.get_field(k).help_text,
widget=TimePickerWidget(without_seconds=True),
widget=TimePickerWidget(),
required=False,
)
@@ -507,12 +506,6 @@ class TimeForm(forms.Form):
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['time_from'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
self.fields['time_to'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
self.fields['time_admission'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
TimeFormSet = formset_factory(
TimeForm,
@@ -1,12 +1,41 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "Event logs" %}{% endblock %}
{% block inside %}
<h1>{% trans "Event logs" %}</h1>
{% include "pretixcontrol/fragment_log_filter_form.html" %}
<form class="form-inline helper-display-inline" action="" method="get">
<input type="hidden" name="content_type" value="{{ request.GET.content_type }}">
<input type="hidden" name="object" value="{{ request.GET.object }}">
<p>
<select name="user" class="form-control">
<option value="">{% trans "All actions" %}</option>
<option value="yes" {% if request.GET.user == "yes" %}selected="selected"{% endif %}>
{% trans "Team actions" %}
</option>
<option value="no" {% if request.GET.user == "no" %}selected="selected"{% endif %}>
{% trans "Customer actions" %}
</option>
{% for up in userlist %}
{% if up.user__id %}
<option value="{{ up.user__id }}"
{% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
{{ up.user__email }}
</option>
{% endif %}
{% endfor %}
{% for d in devicelist %}
{% if d.device__id %}
<option value="d-{{ d.device__id }}"
{% if "d-" in request.GET.user and request.GET.user|slice:"2:" == d.device__id|slugify %}selected="selected"{% endif %}>
{{ d.device__name }}
</option>
{% endif %}
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</p>
</form>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item logentry">
@@ -66,5 +95,5 @@
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination_huge.html" %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
@@ -1,38 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
{% load icon %}
<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="col-md-3 col-xs-6">
{% bootstrap_field filter_form.source %}
</div>
<div class="col-md-3 col-xs-6">
{% bootstrap_field filter_form.device %}
</div>
<div class="col-md-3 col-xs-6">
{% bootstrap_field filter_form.user_email %}
</div>
</div>
{{ filter_form.action_type }}
{{ filter_form.content_type }}
{{ filter_form.object }}
{% if filter_form.is_valid and filter_form.cleaned_data.action_type %}
{% icon "filter" %} <code>{{ filter_form.cleaned_data.action_type }}</code>
{% endif %}
{% if filter_form.is_valid and filter_form.cleaned_data.content_type and filter_form.cleaned_data.object %}
{% icon "filter" %} {% trans "Specific object selected" %}
{% endif %}
<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>
@@ -137,15 +137,6 @@
</td>
<td>
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
{% if staff_session %}
<details class="admin-only">
<summary>
<i class="fa fa-angle-down collapse-indicator"></i>
{% trans "Details" %}
</summary>
<pre class="admin-only">{{ d.info|pprint }}</pre>
</details>
{% endif %}
</td>
<td>
{% if d.initialized %}
@@ -4,7 +4,24 @@
{% block title %}{% trans "Organizer logs" %}{% endblock %}
{% block inside %}
<h1>{% trans "Organizer logs" %}</h1>
{% include "pretixcontrol/fragment_log_filter_form.html" %}
<form class="form-inline helper-display-inline" action="" method="get">
<input type="hidden" name="content_type" value="{{ request.GET.content_type }}">
<input type="hidden" name="object" value="{{ request.GET.object }}">
<p>
<select name="user" class="form-control">
<option value="">{% trans "All actions" %}</option>
{% for up in userlist %}
{% if up.user__id %}
<option value="{{ up.user__id }}"
{% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
{{ up.user__email }}
</option>
{% endif %}
{% endfor %}
</select>
<button class="btn btn-primary" type="submit">{% trans "Filter" %}</button>
</p>
</form>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item logentry">
@@ -64,5 +81,5 @@
</div>
{% endfor %}
</ul>
{% include "pretixcontrol/pagination_huge.html" %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
+15 -6
View File
@@ -65,7 +65,6 @@ from pretix.base.forms.auth import (
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import handle_login_source, session_login
logger = logging.getLogger(__name__)
@@ -320,12 +319,19 @@ class Forgot(TemplateView):
if self.form.is_valid():
email = self.form.cleaned_data['email']
has_redis = settings.HAS_REDIS
try:
user = User.objects.get(is_active=True, auth_backend='native', email__iexact=email)
if rate_limit("pwreset", user.pk, max_num=1, expire_time=3600 * 24):
user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
raise RepeatedResetDenied()
if has_redis:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if rc.exists('pretix_pwreset_%s' % (user.id)):
user.log_action('pretix.control.auth.user.forgot_password.denied.repeated')
raise RepeatedResetDenied()
else:
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
except User.DoesNotExist:
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
@@ -338,7 +344,6 @@ class Forgot(TemplateView):
user.log_action('pretix.control.auth.user.forgot_password.mail_sent')
finally:
has_redis = settings.HAS_REDIS
if has_redis:
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions. '
'Please note that we will send at most one email every 24 hours.'))
@@ -407,7 +412,11 @@ class Recover(TemplateView):
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
rate_limit_reset("pwreset", user.pk)
has_redis = settings.HAS_REDIS
if has_redis:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
rc.delete('pretix_pwreset_%s' % user.id)
return redirect('control:auth.login')
else:
return self.get(request, *args, **kwargs)
+2 -2
View File
@@ -253,7 +253,7 @@ def quota_widgets(sender, subevent=None, lazy=False, **kwargs):
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(left), intcomma(q.size)) if q.size is not None else '\u221e',
text=format_html(_('{quota} left'), quota=q.name)
text=_('{quota} left').format(quota=escape(q.name))
),
'lazy': 'quota-{}'.format(q.pk),
'display_size': 'small',
@@ -307,7 +307,7 @@ def checkin_widget(sender, subevent=None, lazy=False, **kwargs):
'content': None if lazy else format_html(
NUM_WIDGET,
num='{}/{}'.format(intcomma(cl.inside_count), intcomma(cl.position_count)),
text=format_html(_('Present {list}'), list=cl.name)
text=_('Present {list}').format(list=escape(cl.name))
),
'lazy': 'checkin-{}'.format(cl.pk),
'display_size': 'small',
+21 -11
View File
@@ -60,7 +60,7 @@ from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
@@ -119,9 +119,8 @@ from ...helpers.compat import CompatDeleteView
from ...helpers.format import (
PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from ..forms.filter import LogFilterForm
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, LargeResultSetPaginator, PaginationMixin, UpdateView
from . import CreateView, PaginationMixin, UpdateView
logger = logging.getLogger(__name__)
@@ -1254,7 +1253,6 @@ class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixi
class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/event/logs.html'
model = LogEntry
paginator_class = LargeResultSetPaginator
context_object_name = 'logs'
def get_queryset(self):
@@ -1284,20 +1282,32 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
]
qs = qs.filter(content_type__in=allowed_types)
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if self.request.GET.get('user') == 'yes':
qs = qs.filter(user__isnull=False)
elif self.request.GET.get('user') == 'no':
qs = qs.filter(user__isnull=True)
elif self.request.GET.get('user', '').startswith('d-'):
qs = qs.filter(device_id=self.request.GET.get('user')[2:])
elif self.request.GET.get('user'):
qs = qs.filter(user_id=self.request.GET.get('user'))
if self.request.GET.get('action_type'):
qs = qs.filter(action_type=self.request.GET['action_type'])
if self.request.GET.get('content_type'):
qs = qs.filter(content_type=get_object_or_404(ContentType, pk=self.request.GET.get('content_type')))
if self.request.GET.get('object'):
qs = qs.filter(object_id=self.request.GET.get('object'))
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
ctx['userlist'] = self.request.event.logentry_set.order_by().distinct().values('user__id', 'user__email')
ctx['devicelist'] = self.request.event.logentry_set.order_by('device__name').distinct().values('device__id', 'device__name')
return ctx
@cached_property
def filter_form(self):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class EventComment(EventPermissionRequiredMixin, View):
permission = 'event.settings.general:write'
+9 -3
View File
@@ -35,7 +35,7 @@ from django.utils.translation import ngettext
from django.views import View
from django.views.generic import DetailView, ListView
from pretix.base.middleware import add_to_response_csp
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.models import OutgoingMail
from pretix.base.services.mail import mail_send_task
from pretix.control.forms.filter import OutgoingMailFilterForm
@@ -108,12 +108,18 @@ class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequir
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
add_to_response_csp(response, {
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
csps = {
'frame-src': ['data:'],
# Unfortuantely, we can't avoid unsafe-inline for style here.
# See outgoingmail.js for the protection measures we take.
'style-src': ["'unsafe-inline'"],
})
}
_merge_csp(h, csps)
response['Content-Security-Policy'] = _render_csp(h)
return response
def get_context_data(self, **kwargs):
+6 -13
View File
@@ -113,8 +113,7 @@ from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm
from pretix.control.forms.filter import (
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
LogFilterForm, OrganizerFilterForm, ReusableMediaFilterForm,
TeamFilterForm,
OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm,
)
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
@@ -134,7 +133,7 @@ from pretix.control.permissions import (
organizer_permission_required,
)
from pretix.control.signals import nav_organizer
from pretix.control.views import LargeResultSetPaginator, PaginationMixin
from pretix.control.views import PaginationMixin
from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.helpers import OF_SELF, GroupConcat
from pretix.helpers.compat import CompatDeleteView
@@ -2664,7 +2663,6 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/organizers/logs.html'
permission = 'organizer.settings.general:write'
model = LogEntry
paginator_class = LargeResultSetPaginator
context_object_name = 'logs'
def get_queryset(self):
@@ -2674,21 +2672,16 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
'user', 'content_type', 'api_token', 'oauth_application', 'device'
).order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if self.request.GET.get('action_type'):
qs = qs.filter(action_type=self.request.GET['action_type'])
if self.request.GET.get('user'):
qs = qs.filter(user_id=self.request.GET.get('user'))
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return LogFilterForm(data=self.request.GET, organizer=self.request.organizer)
class MembershipTypeListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = MembershipType
+1 -2
View File
@@ -85,7 +85,6 @@ from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.helpers import GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.i18n import get_format_without_seconds
from pretix.helpers.models import modelcopy
@@ -879,7 +878,7 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
ctx['rrule_formset'] = self.rrule_formset
ctx['time_formset'] = self.time_formset
tf = get_format_without_seconds('TIME_INPUT_FORMATS')
tf = get_format('TIME_INPUT_FORMATS')[0]
ctx['time_admission_sample'] = time(8, 30, 0).strftime(tf)
ctx['time_begin_sample'] = time(9, 0, 0).strftime(tf)
ctx['time_end_sample'] = time(18, 0, 0).strftime(tf)
-7
View File
@@ -80,7 +80,6 @@ from pretix.control.permissions import (
)
from pretix.control.views.auth import get_u2f_appid, get_webauthn_rp_id
from pretix.helpers.http import redirect_to_url
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import session_reauth
from pretix.helpers.u2f import websafe_encode
@@ -850,7 +849,6 @@ class UserPasswordChangeView(FormView):
msgs = []
msgs.append(_('Your password has been changed.'))
self.request.user.send_security_notice(msgs)
rate_limit_reset("pwreset", self.request.user.pk)
self.request.user.log_action('pretix.user.settings.changed', user=self.request.user, data={'new_pw': True})
@@ -881,7 +879,6 @@ class UserEmailChangeView(RecentAuthenticationRequiredMixin, FormView):
return {
**super().get_form_kwargs(),
"request": self.request,
"user": self.request.user,
}
@@ -911,10 +908,6 @@ class UserEmailVerifyView(View):
messages.success(self.request, _('Your email address was already verified.'))
return redirect(reverse('control:user.settings', kwargs={}))
if rate_limit("emailverify", self.request.user.pk, max_num=2, expire_time=300):
messages.error(self.request, _("For security reasons, please wait 5 minutes before you try again."))
return redirect(reverse('control:user.settings', kwargs={}))
self.request.user.send_confirmation_code(
session=self.request.session,
reason='email_verify',
+13 -4
View File
@@ -19,6 +19,7 @@
# 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/>.
#
import ipaddress
import socket
import sys
import types
@@ -41,7 +42,8 @@ from urllib3.util.connection import (
from urllib3.util.timeout import _DEFAULT_TIMEOUT
from pretix.helpers.reportlab import ThumbnailingImageReader
from pretix.helpers.ssrf import should_block_access
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def monkeypatch_vobject_performance():
@@ -148,9 +150,16 @@ def monkeypatch_urllib3_ssrf_protection():
af, socktype, proto, canonname, sa = res
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
is_private, msg = should_block_access(sa)
if is_private:
raise HTTPError(msg)
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise HTTPError(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise HTTPError(f"Request to private address {sa[0]} blocked")
if check_ip4 in _cgnat_net:
raise HTTPError(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
try:
-116
View File
@@ -1,116 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
import hashlib
import ipaddress
import logging
from django.conf import settings
from django.http import HttpRequest
from pretix.helpers.http import get_client_ip
logger = logging.getLogger(__name__)
def _get_key(key, parameters):
return f'pretix:ratelimit:{key}:' + hashlib.sha256(','.join(str(p) for p in parameters).encode()).hexdigest()
def _get_ip(request):
client_ip = get_client_ip(request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private and not settings.DEBUG:
# This is the private IP of the server, web server not set up correctly
return None
return str(client_ip)
def rate_limit(key: str, *parameters, include_ip_from_request: HttpRequest=None, max_num: int, expire_time: int, increase: bool = True):
"""
This is a shared utility to implement simple rate limiting in operations like
password resets.
:param key: The key referring to the feature like "pwreset"
:param parameters: Any number of things to be hashed as the bucket key
:param include_ip_from_request: Add IP address from request to the bucket key. If IP address cannot be determined,
rate limit is not applied.
:param max_num: The maximum number of actions to performed within expire_time of the first action
:param expire_time: The length of the time window in seconds
:param increase: Whether to count the call as an event counted towards the rate, or just check
:return:
"""
if not settings.HAS_REDIS:
# No rate limiting
return False
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if include_ip_from_request:
ip = _get_ip(include_ip_from_request)
if not ip:
# IP not discovered, can't rate limit
return False
parameters = (*parameters, ip)
redis_key = _get_key(key, parameters)
if increase:
p = rc.pipeline()
p.set(redis_key, 0, nx=True, ex=expire_time) # Start a rate limit window if none is running
p.incr(redis_key)
new_counter = p.execute()[1]
else:
new_counter = int(rc.get(redis_key) or 0)
if new_counter > max_num:
return True
return False
def rate_limit_reset(key: str, *parameters, include_ip_from_request: HttpRequest=None):
"""
Reset a rate limit bucket.
"""
if not settings.HAS_REDIS:
# No rate limiting
return
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if include_ip_from_request:
ip = _get_ip(include_ip_from_request)
if not ip:
# IP not discovered, can't rate limit
return False
parameters = (*parameters, ip)
redis_key = _get_key(key, parameters)
rc.delete(redis_key)
-40
View File
@@ -1,40 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
import ipaddress
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def should_block_access(sa):
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
return True, f"Request to multicast address {sa[0]} blocked"
if ip_addr.is_loopback or ip_addr.is_link_local:
return True, f"Request to local address {sa[0]} blocked"
if ip_addr.is_private:
return True, f"Request to private address {sa[0]} blocked"
if check_ip4 in _cgnat_net:
return True, f"Request to RFC 6598 address {sa[0]} blocked"
return False, None
+2 -2
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-07 09:30+0000\n"
"PO-Revision-Date: 2026-07-20 08:01+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"PO-Revision-Date: 2026-07-13 12:35+0000\n"
"Last-Translator: ahmed badr <ahmedbadr@oma.com.eg>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix/"
"ar/>\n"
"Language: ar\n"
+1 -8
View File
@@ -73,9 +73,7 @@ from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.plugins.banktransfer.refund_export import (
build_sepa_xml, get_refund_export_csv,
)
from pretix.plugins.banktransfer.tasks import (
notify_incomplete_payment, process_banktransfers,
)
from pretix.plugins.banktransfer.tasks import process_banktransfers
logger = logging.getLogger('pretix.plugins.banktransfer')
@@ -167,11 +165,6 @@ class ActionView(View):
provider='banktransfer',
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING),
).update(state=OrderPayment.PAYMENT_STATE_CANCELED)
trans.order.refresh_from_db()
if trans.order.pending_sum > Decimal('0.00') and trans.order.status == Order.STATUS_PENDING:
notify_incomplete_payment(trans.order)
return JsonResponse({
'status': 'ok',
})
+2 -4
View File
@@ -44,7 +44,6 @@ from django.contrib import messages
from django.http import HttpRequest
from django.template.loader import get_template
from django.urls import reverse
from django.utils.html import format_html
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from i18nfield.strings import LazyI18nString
@@ -113,10 +112,9 @@ class Paypal(BasePaymentProvider):
label=_('Client ID'),
max_length=80,
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
help_text=_('<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>').format(
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html',
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html'
)
)),
('secret',
+1 -3
View File
@@ -36,7 +36,6 @@ from django.template.loader import get_template
from django.templatetags.static import static
from django.urls import resolve, reverse
from django.utils.crypto import get_random_string
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
@@ -111,8 +110,7 @@ class PaypalSettingsHolder(BasePaymentProvider):
label=_('Client ID'),
max_length=80,
min_length=80,
help_text=format_html(
'<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>',
help_text=_('<a target="_blank" rel="noopener" href="{docs_url}">{text}</a>').format(
text=_('Click here for a tutorial on how to obtain the required keys'),
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html'
)
+13 -3
View File
@@ -32,7 +32,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.forms import SecretKeySettingsField
from pretix.base.logentrytypes import EventLogEntryType, log_entry_types
from pretix.base.middleware import add_to_response_csp
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
register_global_settings, register_payment_providers,
@@ -144,7 +144,12 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
(url.namespace == "plugins:paypal2" and url.url_name == "pay")
):
add_to_response_csp(response, {
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
csps = {
'script-src': ['https://www.paypal.com', "'nonce-{}'".format(_nonce(request))],
# When the stars align in an unpredictable manner and the temperature is just right, the PayPal SDK might
@@ -159,7 +164,12 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
'connect-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com'], # Or not - seems to only affect PayPal logging...
'img-src': ['https://t.paypal.com', 'https://www.paypalobjects.com'],
'style-src': ["'unsafe-inline'"] # PayPal does not comply with our nonce unfortunately, see Z#23113213
})
}
_merge_csp(h, csps)
if h:
response['Content-Security-Policy'] = _render_csp(h)
return response
+3 -11
View File
@@ -22,7 +22,6 @@
import re
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -34,22 +33,15 @@ from pretix.control.views.event import (
class ReturnSettingsForm(SettingsForm):
returnurl_prefix = forms.CharField(
returnurl_prefix = forms.RegexField(
label=_("Base redirection URLs"),
help_text=_("Redirection will only be allowed to URLs that start with one of these prefixes. "
"Enter one allowed URL prefix per line. "
"Enter one or more allowed URL prefix per line. "
"URL prefixes must include a slash after the hostname."),
required=False,
widget=forms.Textarea,
regex=re.compile(r'^((https://.*/.*|http://localhost[:/].*)\n*)*$')
)
line_regex = re.compile(r'^(https://.*/.*|http://localhost(:[0-9]+)?/.*)$')
def clean_returnurl_prefix(self):
val = self.cleaned_data['returnurl_prefix']
for l in val.split("\n"):
if not re.match(self.line_regex, l):
raise ValidationError(_('All values must be URLs that include at last one slash after the hostname.'))
return val
class ReturnSettings(EventSettingsViewMixin, EventSettingsFormView):
+9 -18
View File
@@ -230,15 +230,10 @@ class OrderMailForm(BaseMailForm):
self.fields['attach_tickets'].help_text = _("Attachment of tickets is disabled in this event's email "
"settings.")
choices = [
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
('valid_if_pending', _('payment pending but already confirmed')),
('na', _('payment pending (except unapproved or already confirmed)')),
('pa', _('approval pending')),
(Order.STATUS_CANCELED, _('Canceled (fully)')),
(Order.STATUS_EXPIRED, _("Expired")),
]
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('valid_if_pending', _('payment pending but already confirmed')))
choices.insert(0, ('na', _('payment pending (except unapproved or already confirmed)')))
choices.insert(0, ('pa', _('approval pending')))
if not event.settings.get('payment_term_expire_automatically', as_type=bool):
choices.append(
('overdue', _('pending with payment overdue'))
@@ -387,15 +382,11 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
self._set_field_placeholders('subject', ['event', 'order', 'event_or_subevent'])
self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent'], rich=True)
choices = [
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
('n__valid_if_pending', _('payment pending but already confirmed')),
('n__not_pending_approval_and_not_valid_if_pending',
_('payment pending (except unapproved or already confirmed)')),
('n__pending_approval', _('approval pending')),
(Order.STATUS_CANCELED, _('Canceled (fully)')),
(Order.STATUS_EXPIRED, _("Expired")),
]
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('n__valid_if_pending', _('payment pending but already confirmed')))
choices.insert(0, ('n__not_pending_approval_and_not_valid_if_pending',
_('payment pending (except unapproved or already confirmed)')))
choices.insert(0, ('n__pending_approval', _('approval pending')))
if not self.event.settings.get('payment_term_expire_automatically', as_type=bool):
choices.append(
('n__pending_overdue', _('pending with payment overdue'))
+14 -3
View File
@@ -31,7 +31,7 @@ from django.utils.translation import gettext_lazy as _
from paypalhttp import HttpResponse
from pretix.base.forms import SecretKeySettingsField
from pretix.base.middleware import add_to_response_csp
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
logentry_display, register_global_settings, register_payment_providers,
@@ -201,10 +201,21 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
(url.namespace == "plugins:stripe" and url.url_name in ["sca", "sca.return"])
):
add_to_response_csp(response, {
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
# https://stripe.com/docs/security/guide#content-security-policy
csps = {
'connect-src': ['https://api.stripe.com'],
'frame-src': ['https://js.stripe.com', 'https://hooks.stripe.com'],
'script-src': ['https://js.stripe.com'],
})
}
_merge_csp(h, csps)
if h:
response['Content-Security-Policy'] = _render_csp(h)
return response
+56 -20
View File
@@ -20,6 +20,8 @@
# <https://www.gnu.org/licenses/>.
#
import functools
import hashlib
import ipaddress
import random
from django import forms
@@ -30,6 +32,7 @@ from django.contrib.auth.password_validation import (
)
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.core import signing
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _
from phonenumber_field.formfields import PhoneNumberField
@@ -41,7 +44,6 @@ from pretix.base.forms.questions import (
from pretix.base.i18n import get_language_without_region
from pretix.base.models import Customer
from pretix.helpers.http import get_client_ip
from pretix.helpers.ratelimit import rate_limit
from pretix.multidomain.urlreverse import eventreverse_absolute
@@ -203,6 +205,23 @@ class RegistrationForm(forms.Form):
min_value=0,
)
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
email = self.cleaned_data.get('email')
@@ -236,11 +255,17 @@ class RegistrationForm(forms.Form):
code='incomplete'
)
else:
if rate_limit("customer_signup", include_ip_from_request=self.request, max_num=10, expire_time=600):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 600)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return self.cleaned_data
def create(self):
@@ -334,11 +359,6 @@ class ResetPasswordForm(forms.Form):
def clean_email(self):
if 'email' not in self.cleaned_data:
return
if rate_limit("customer_pwreset_check", max_num=10, expire_time=600):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
try:
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'].lower(), provider__isnull=True)
return self.customer.email
@@ -350,8 +370,13 @@ class ResetPasswordForm(forms.Form):
def clean(self):
d = super().clean()
if d.get('email'):
if rate_limit("customer_pwreset", self.customer.pk, max_num=2, expire_time=600):
if d.get('email') and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk)
rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600)
if cnt > 2:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
@@ -420,8 +445,13 @@ class ChangePasswordForm(forms.Form):
def clean_password_current(self):
old_pw = self.cleaned_data.get('password_current')
if old_pw:
if rate_limit("customer_pwchange", self.customer.pk, max_num=10, expire_time=300):
if old_pw and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk)
rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
@@ -491,11 +521,17 @@ class ChangeInfoForm(forms.ModelForm):
old_pw = self.cleaned_data.get('password_current')
if old_pw:
if rate_limit("customer_pwchange", self.instance.pk, max_num=10, expire_time=300):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk)
rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.instance.password):
raise forms.ValidationError(
@@ -1,7 +1,6 @@
{% load i18n %}
{% load icon %}
{% load eventurl %}
{% load date_fast %}
<div class="event-list full-width-list alternating-rows">
{% for subev in subevent_list.subevent_list %}
@@ -18,14 +17,8 @@
<span data-time="{{ subev.date_from.isoformat }}" data-timezone="{{ event.timezone }}" data-time-short>
{% icon "clock-o" %}
<span class="sr-only">{% trans "Time of day" %}</span>
<time datetime="{{ subev.date_from|date_fast:"H:i" }}">{{ subev.date_from|date_fast:"TIME_FORMAT" }}</time>
<time datetime="{{ subev.date_from.isoformat }}">{{ subev.date_from|date:"TIME_FORMAT" }}</time>
</span>
{% if event.settings.show_date_to and subev.date_to and subev.is_same_day %}
<span data-time="{{ subev.date_to.isoformat }}" data-timezone="{{ event.timezone }}" data-time-short>
<span aria-hidden="true"></span><span class="sr-only">{% trans "until" context "timerange" %}</span>
<time datetime="{{ subev.date_to|date_fast:"H:i" }}">{{ subev.date_to|date_fast:"TIME_FORMAT" }}</time>
</span>
{% endif %}
{% endif %}
</p>
<p class="col-md-3 col-xs-6">
@@ -67,7 +67,7 @@
{% icon "clock-o" %}
<span class="sr-only">{% trans "Time of day" %}</span>
<time datetime="{{ e.date_from|date_fast:"H:i" }}">{{ e.date_from|date:"TIME_FORMAT" }}</time>
{% if e.settings.show_date_to and e.date_to and e.is_same_day %}
{% if e.settings.show_date_to and e.date_to and e.date_to.date == e.date_from.date %}
<span aria-hidden="true"></span><span class="sr-only">{% trans "until" context "timerange" %}</span>
<time datetime="{{ e.date_to|date_fast:"H:i" }}">{{ e.date_to|date:"TIME_FORMAT" }}</time>
{% endif %}
+12 -7
View File
@@ -32,6 +32,7 @@
# 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.
from django.conf import settings
from django.contrib import messages
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -41,7 +42,6 @@ from django.views.generic import TemplateView
from pretix.base.email import get_email_context
from pretix.base.services.mail import INVALID_ADDRESS, mail
from pretix.helpers.http import redirect_to_url
from pretix.helpers.ratelimit import rate_limit
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.user import ResendLinkForm
from pretix.presale.views import EventViewMixin
@@ -61,12 +61,17 @@ class ResendLinkView(EventViewMixin, TemplateView):
user = self.link_form.cleaned_data.get('email')
if rate_limit("order_resend", self.request.event.pk, user, max_num=1, expire_time=3600 * 24):
messages.error(request, _('If the email address you entered is valid and associated with a ticket, we have '
'already sent you an email with a link to your ticket in the past {number} hours. '
'If the email did not arrive, please check your spam folder and also double check '
'that you used the correct email address.').format(number=24))
return redirect_to_url(eventreverse(self.request.event, 'presale:event.resend_link'))
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
if rc.exists('pretix_resend_{}_{}'.format(request.event.pk, user)):
messages.error(request, _('If the email address you entered is valid and associated with a ticket, we have '
'already sent you an email with a link to your ticket in the past {number} hours. '
'If the email did not arrive, please check your spam folder and also double check '
'that you used the correct email address.').format(number=24))
return redirect_to_url(eventreverse(self.request.event, 'presale:event.resend_link'))
else:
rc.setex('pretix_resend_{}_{}'.format(request.event.pk, user), 3600 * 24, '1')
orders = self.request.event.orders.filter(email__iexact=user)
-1
View File
@@ -512,7 +512,6 @@ REST_FRAMEWORK = {
MIDDLEWARE = [
'pretix.helpers.logs.RequestIdMiddleware',
'pretix.base.middleware.BaseLocaleMiddleware',
'pretix.api.middleware.IdempotencyMiddleware',
'pretix.multidomain.middlewares.MultiDomainMiddleware',
'django_querytagger.middleware.SetTagMiddleware', # after MultiDomainMiddleware for correct url resolving
@@ -124,7 +124,7 @@ var form_handlers = function (el) {
el.find(".datetimepicker").each(function () {
$(this).datetimepicker({
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-datetimeformat"),
format: $("body").attr("data-datetimeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -147,7 +147,7 @@ var form_handlers = function (el) {
el.find(".datepickerfield").each(function () {
var opts = {
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-dateformat"),
format: $("body").attr("data-dateformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -205,7 +205,7 @@ var form_handlers = function (el) {
el.find(".timepickerfield").each(function () {
var opts = {
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-timeformat"),
format: $("body").attr("data-timeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -464,8 +464,6 @@ details.details-open .panel-title::before {
.alert > dl:last-child,
td > p:last-child,
.panel-body > dl:last-child,
.panel-body > ul:last-child,
.panel-body > ol:last-child,
.panel-body > .table:last-child,
.panel-body > .table-responsive:last-child > .table:last-child,
table td ul:last-child {
@@ -11,7 +11,7 @@ var form_handlers = function (el) {
el.find(".datetimepicker").each(function () {
$(this).datetimepicker({
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-datetimeformat"),
format: $("body").attr("data-datetimeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -34,7 +34,7 @@ var form_handlers = function (el) {
el.find(".datepickerfield").each(function () {
var opts = {
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-dateformat"),
format: $("body").attr("data-dateformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -91,7 +91,7 @@ var form_handlers = function (el) {
el.find(".timepickerfield").each(function () {
var opts = {
format: $(this).attr("data-format") ? $(this).attr("data-format") : $("body").attr("data-timeformat"),
format: $("body").attr("data-timeformat"),
locale: $("body").attr("data-datetimelocale"),
useCurrent: false,
showClear: !$(this).prop("required"),
@@ -60,18 +60,8 @@ export function createButtonInstance (element: Element, htmlId?: string): App {
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-button]', info, error)
}
// Instead of mounting to the element directly, replicate vue2.7 behaviour, where
// the HTML-element gets replaced instead of vue3s behaviour to replace innerHTML.
// The latter can cause issues with custom CSS as HTML structure would change.
// app.mount(element)
// observer.observe(element, { attributes: true })
const fragment = document.createDocumentFragment()
app.mount(fragment)
for (const attr of element.attributes) {
fragment.firstChild.setAttribute(attr.name, attr.value)
}
observer.observe(fragment.firstChild, { attributes: true })
element.parentNode.replaceChild(fragment, element)
app.mount(element)
observer.observe(element, { attributes: true })
return app
}
@@ -56,18 +56,8 @@ export function createWidgetInstance (element: Element, htmlId?: string): App {
app.config.errorHandler = (error, _vm, info) => {
console.error('[pretix-widget]', info, error)
}
// Instead of mounting to the element directly, replicate vue2.7 behaviour, where
// the HTML-element gets replaced instead of vue3s behaviour to replace innerHTML.
// The latter can cause issues with custom CSS as HTML structure would change.
// app.mount(element)
// observer.observe(element, { attributes: true })
const fragment = document.createDocumentFragment()
app.mount(fragment)
for (const attr of element.attributes) {
fragment.firstChild.setAttribute(attr.name, attr.value)
}
observer.observe(fragment.firstChild, { attributes: true })
element.parentNode.replaceChild(fragment, element)
app.mount(element)
observer.observe(element, { attributes: true })
return app
}
+5 -5
View File
@@ -252,7 +252,7 @@ TEST_HISTORY_RES = {
}
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_list_list(token_client, organizer, event, clist, item, subevent, django_assert_num_queries):
res = dict(TEST_LIST_RES)
res["id"] = clist.pk
@@ -422,7 +422,7 @@ def test_list_update(token_client, organizer, event, clist):
assert cl.name == "VIP"
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_list_all_items_positions(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_num_queries):
with scopes_disabled():
p1 = dict(TEST_ORDERPOSITION1_RES)
@@ -680,7 +680,7 @@ def _redeem(token_client, org, clist, p, body=None):
), body or {}, format='json')
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_query_load(token_client, organizer, clist, event, order, django_assert_max_num_queries):
with scopes_disabled():
p = order.positions.first().pk
@@ -1368,7 +1368,7 @@ def test_redeem_addon_if_match_and_revoked_force(token_client, organizer, clist,
assert ci.position == p
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_search(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_max_num_queries):
with scopes_disabled():
p1 = dict(TEST_ORDERPOSITION1_RES)
@@ -1400,7 +1400,7 @@ def test_checkin_pdf_data_requires_permission(token_client, event, team, organiz
assert not resp.data['results'][0].get('pdf_data')
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_expand(token_client, organizer, event, clist, clist_all, item, other_item, order, django_assert_max_num_queries):
with scopes_disabled():
op = order.positions.first()
+2 -2
View File
@@ -214,7 +214,7 @@ def _redeem(token_client, org, clist, p, body=None, query='', headers={}):
), body, format='json', headers={})
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_query_load(token_client, organizer, clist, event, order, django_assert_max_num_queries):
with scopes_disabled():
p = order.positions.first()
@@ -996,7 +996,7 @@ def test_redeem_conflicting_lists(token_client, organizer, clist, clist_all, eve
assert resp.data == ['Selecting two check-in lists from the same event is unsupported.']
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_search(token_client, organizer, event, clist, clist_all, item, other_item, order,
django_assert_max_num_queries):
with scopes_disabled():
+1 -1
View File
@@ -1848,7 +1848,7 @@ def test_event_block_unblock_seat_bulk(token_client, organizer, event, seatingpl
assert not s2.blocked
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_event_expand_seat_filter_and_querycount(token_client, organizer, event, seatingplan, item):
event.settings.seating_minimal_distance = 2
+2 -31
View File
@@ -730,35 +730,6 @@ def test_payment_create_confirmed(token_client, organizer, event, order):
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_payment_create_confirmed_after_expiry(token_client, organizer, event, order):
djmail.outbox = []
order.expires = now() - datetime.timedelta(days=2)
order.save()
event.settings.payment_term_last = (now() - datetime.timedelta(days=2)).strftime('%Y-%m-%d')
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'provider': 'banktransfer',
'state': 'confirmed',
'amount': order.total,
'send_email': False,
'info': {
'foo': 'bar'
},
"force": True
})
with scopes_disabled():
p = order.payments.last()
assert resp.status_code == 201
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert p.info_data == {'foo': 'bar'}
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_payment_create_pending(token_client, organizer, event, order):
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/'.format(
@@ -1087,7 +1058,7 @@ def test_orderposition_list_limited_read(
('/api/v1/organizers/{}/orderpositions/', "organizer")
],
)
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_orderposition_list(
endpoint_template,
endpoint_type,
@@ -2065,7 +2036,7 @@ def test_blocked_secret_list(token_client, organizer, event):
assert [res] == resp.data['results']
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_queries):
# order detail
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/?pdf_data=true'.format(
+3 -3
View File
@@ -732,7 +732,7 @@ def test_five_tickets_one_free(event):
@scopes_disabled()
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
@pytest.mark.parametrize("itemcount", [3, 10, 50])
def test_query_count_many_items(event, itemcount):
setup_items(event, 'Tickets', 'both', 'discounts',
@@ -784,7 +784,7 @@ def test_query_count_many_items(event, itemcount):
@scopes_disabled()
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
@pytest.mark.parametrize("catcount", [1, 10, 50])
def test_query_count_many_categories_and_discounts(event, catcount):
for n in range(1, catcount + 1):
@@ -838,7 +838,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
@scopes_disabled()
@pytest.mark.django_db(transaction=True)
@pytest.mark.django_db
@pytest.mark.parametrize("catcount", [2, 10, 50])
def test_query_count_many_cartpos(event, catcount):
for n in range(1, catcount + 1):
+1 -3
View File
@@ -26,7 +26,6 @@ from zoneinfo import ZoneInfo
import pytest
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.timezone import now
from django_scopes import scope
from freezegun import freeze_time
@@ -211,8 +210,7 @@ def test_validate_membership_required(event, customer, membership, requiring_tic
assert "requires an active" in str(excinfo.value)
@pytest.mark.django_db(transaction=True)
@transaction.atomic
@pytest.mark.django_db
def test_validate_membership_ensure_locking(event, customer, membership, requiring_ticket, membership_type, django_assert_num_queries):
with django_assert_num_queries(4) as captured:
validate_memberships_in_order(
-7
View File
@@ -28,7 +28,6 @@ from django.test import override_settings
from django.utils import translation
from django_scopes import scopes_disabled
from fakeredis import FakeRedisConnection
from hierarkey.proxy import dirty_cache_keys
from xdist.dsession import DSession
from pretix.testutils.mock import get_redis_connection
@@ -83,11 +82,6 @@ def reset_locale():
translation.activate("en")
@pytest.fixture(autouse=True)
def reset_hierarkey_cache_state():
dirty_cache_keys.set(set())
@pytest.fixture
def fakeredis_client(monkeypatch):
worker_id = os.environ.get("PYTEST_XDIST_WORKER")
@@ -126,7 +120,6 @@ def fakeredis_client(monkeypatch):
redis = get_redis_connection("default", True)
redis.flushall()
monkeypatch.setattr('django_redis.get_redis_connection', get_redis_connection, raising=False)
monkeypatch.setattr('pretix.base.metrics.redis', redis, raising=False)
yield redis
+31 -1
View File
@@ -504,8 +504,33 @@ class Login2FAFormTest(TestCase):
assert "recovery code" in djmail.outbox[0].body
class FakeRedis(object):
def get_redis_connection(self, connection_string):
return self
def __init__(self):
self.storage = {}
def pipeline(self):
return self
def hincrbyfloat(self, rkey, key, amount):
return self
def commit(self):
return self
def exists(self, rkey):
return rkey in self.storage
def setex(self, rkey, value, expiration):
self.storage[rkey] = value
def execute(self):
pass
@pytest.mark.usefixtures("class_monkeypatch")
@pytest.mark.usefixtures("fakeredis_client")
class PasswordRecoveryFormTest(TestCase):
def setUp(self):
super().setUp()
@@ -535,6 +560,11 @@ class PasswordRecoveryFormTest(TestCase):
@override_settings(HAS_REDIS=True)
def test_email_reset_twice_redis(self):
fake_redis = FakeRedis()
m = self.monkeypatch
m.setattr('django_redis.get_redis_connection', fake_redis.get_redis_connection, raising=False)
m.setattr('pretix.base.metrics.redis', fake_redis, raising=False)
djmail.outbox = []
response = self.client.post('/control/forgot', {
@@ -50,7 +50,6 @@ def env():
datetime=now(), expires=now() + timedelta(days=10),
total=23,
sales_channel=o.sales_channels.get(identifier="web"),
email='dummy@dummy.dummy'
)
o2 = Order.objects.create(
code='6789Z', event=event,
@@ -130,7 +129,7 @@ def test_assign_order_unknown(env, client):
@pytest.mark.django_db
def test_assign_order_amount_incorrect(env, client, mailoutbox):
def test_assign_order_amount_incorrect(env, client):
job = BankImportJob.objects.create(event=env[0])
trans = BankTransaction.objects.create(event=env[0], import_job=job, payer='Foo',
state=BankTransaction.STATE_NOMATCH,
@@ -142,7 +141,6 @@ def test_assign_order_amount_incorrect(env, client, mailoutbox):
assert r['status'] == 'ok'
trans.refresh_from_db()
assert trans.state == BankTransaction.STATE_VALID
assert len(mailoutbox) == 1
@pytest.mark.django_db