Compare commits

..
Author SHA1 Message Date
dependabot[bot] e9f2af7307 Update djangorestframework requirement from ==3.17.* to ==3.18.*
Updates the requirements on [djangorestframework](https://github.com/encode/django-rest-framework) to permit the latest version.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.17.0...3.18.0)

---
updated-dependencies:
- dependency-name: djangorestframework
  dependency-version: 3.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-09 12:11:23 +00:00
Ferdinand Malcher 6025c7942f Product pictures: fix CSS to always fit image to viewport (#6527) 2026-09-09 14:10:15 +02:00
dependabot[bot] 23a3412154 Update django-statici18n requirement from ==2.7.* to ==2.8.* (#6529)
Updates the requirements on [django-statici18n](https://github.com/zyegfryed/django-statici18n) to permit the latest version.
- [Changelog](https://github.com/zyegfryed/django-statici18n/blob/main/docs/changelog.rst)
- [Commits](https://github.com/zyegfryed/django-statici18n/compare/v2.7.0...v2.8.0)

---
updated-dependencies:
- dependency-name: django-statici18n
  dependency-version: 2.8.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 14:08:54 +02:00
Raphael MichelandRichard Schreiber 3635f6fb0c Widget: Add data-fix equivalent for attendee data (Z#23202714) (#6533)
* Widget: Add data-fix equivalent for attendee data (Z#23202714)

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-09 14:06:41 +02:00
Kara Engelhardt 2aadfa2ce4 RRuleForm: allow selecting the fourth occurence of a weekday of a month (Z#23245893)
The fourth often is the same as the last, but not always. A weekday can appear five times in a month
2026-09-09 12:21:42 +02:00
Raphael Michel dd0d78242e PDF editor: Harden cached file access for background (Z#23242291) (#6446) 2026-09-09 11:13:29 +02:00
Raphael Michelandluelista 42c5de895c CachedFileField: Bind file access to session (Z#23242301) (#6447)
* CachedFileField: Bind file access to session (Z#23242301)

* Apply suggestion from @luelista

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:40 +02:00
Raphael Michelandluelista e91718e73b Password reset: Prevent race condition that uses token twice (Z#23242132) (#6448)
* Password reset: Prevent race condition that uses token twice

* Apply suggestion from @luelista

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:38 +02:00
Martin GrossandRaphael Michel 2c15d8b074 Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623) (#6506)
* Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623)

* isort

* Update doc/api/resources/events.rst

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-09 09:14:37 +02:00
Raphael MichelandLukas Bockstaller edb4069e18 Payment step: Allow to postpone payment choice on some sales channels (#6516)
* Payment step: Allow to postpone payment choice on some sales channels

* Add tests

* handle payment provider (de-)selection and partial payments (#6526)

---------

Co-authored-by: Lukas Bockstaller <bockstaller@pretix.eu>
2026-09-08 09:32:15 +02:00
Raphael MichelandRichard Schreiber dc7d5c6029 Event calendar: consider events without products "not available" for filtering (#6515)
* Event calendar: consider events without products "not available" for filtering

Also, drop the help text of the flag that is no longer accurate.

* Update src/pretix/presale/views/organizer.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-04 16:29:46 +02:00
Raphael Michel caa6fb187b VAT ID validation: Alternative API for German shops (#6507) 2026-09-02 17:48:38 +02:00
Lukas Bockstaller 7d93cae2a9 set the _required attribute on the ModelMultipleChoiceField (#6514)
setting _required forces the CheckoutFieldRenderer to add the "required" bit to the label.
We only need to remove it from the widget attrs to avoid the html input field validation that would force us to check every single box
2026-09-02 10:35:19 +02:00
Richard Schreiber 58a58eff83 Widget: fix show taxline for 0 or 0.00 tax-rate (#6502) 2026-09-01 13:19:32 +02:00
Martin Gross a84a02c298 Vite: Add dev CORS (#6511) 2026-09-01 11:22:19 +02:00
Martin Gross 4390403b9a Add label-overrides to contact_form_fields_overrides and question_form_fields_overrides (Z#23244154) (#6504) 2026-09-01 10:21:20 +02:00
Lukas Bockstaller 9d53cf840b PayPal: validate that the sale has any captures before marking paid (#6498)
* validate that the sale has any captures before marking paid

* code style
2026-09-01 10:16:51 +02:00
luelista 023f9104ef Fix customer password reset rate limit (#6501) 2026-08-31 16:48:48 +02:00
Lukas Bockstaller e6572344ca CI: add tracing for e2e tests during failure (#6491)
* collect and upload traces on failure

* include deps for pw install
2026-08-31 12:52:06 +02:00
37 changed files with 677 additions and 152 deletions
+19 -2
View File
@@ -123,7 +123,24 @@ jobs:
working-directory: ./src
run: make all compress
- name: Install Playwright browsers
run: playwright install
run: playwright install --with-deps
- name: Run E2E tests
working-directory: ./src
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10 --tracing=retain-on-failure
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-traces
path: test-results/
- name: Log trace instructions
if: steps.check-traces.outputs.found == 'true'
run: |
{
echo "## 🎭 Playwright traces available"
echo ""
echo "Some tests failed or retried and produced traces."
echo ""
echo "1. Download the **playwright-traces-${{ github.run_id }}** artifact from this run (link in the **Summary** tab, under Artifacts)."
echo "2. Unzip it."
echo "3. Go to https://trace.playwright.dev and drag \`trace.zip\` into the page — or run \`npx playwright show-trace trace.zip\` locally."
} >> "$GITHUB_STEP_SUMMARY"
+9 -7
View File
@@ -566,7 +566,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://pretix.eu",
"region": "DE",
}
@@ -579,12 +579,14 @@ organizer level.
Content-Type: application/json
{
"imprint_url":
"region":
{
"value": "https://pretix.eu",
"label": "Imprint URL",
"value": "DE",
"label": "Region",
"readonly": false,
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
"help_text": "Will be used to determine date and time formatting as well as default country for customer
addresses and phone numbers. For formatting, this takes less priority than the language and
is therefore mostly relevant for languages used in different regions globally (like English)."
}
},
@@ -620,7 +622,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/"
"region": "DE"
}
**Example response**:
@@ -632,7 +634,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/",
"region": "DE",
}
+2 -2
View File
@@ -56,8 +56,8 @@ dependencies = [
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-scopes==2.1.*",
"django-statici18n==2.7.*",
"djangorestframework==3.17.*",
"django-statici18n==2.8.*",
"djangorestframework==3.18.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
"geoip2==5.*",
+3 -2
View File
@@ -899,7 +899,7 @@ class BaseQuestionsForm(forms.Form):
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field.required = False
return field
@@ -1497,7 +1497,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
pass # Skip re-validation if it is validated
elif self.validate_vat_id and vat_id_applicable:
try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
requester_id = self.request.event.settings.invoice_address_from_vat_id
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')), requester_id)
self.instance.vat_id_validated = bool(normalized_id)
self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e:
+7 -40
View File
@@ -626,47 +626,14 @@ class Order(LockModel, LoggedModel):
self.save(update_fields=['last_modified'])
def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
from pretix.base.services.payment import compute_payment_deadline
sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_")
if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = self.event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if self.event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
elif mode == 'minutes':
exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
self.expires = exp_by_date
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if self.event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return
term_last = min(terms)
else:
term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < self.expires:
self.expires = term_last
self.expires = compute_payment_deadline(
event=self.event,
sales_channel=self.sales_channel,
now_dt=now_dt,
subevents=subevents,
)
@cached_property
def tax_total(self):
+1
View File
@@ -1605,6 +1605,7 @@ def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
cart_session['payments_postpone'] = False
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
+18 -3
View File
@@ -961,7 +961,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
meta_info: dict, event: Event, sales_channel: SalesChannel, require_approval=False):
fees = []
# Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
@@ -1021,7 +1021,14 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
allow_postponed_payment = (
require_approval or
(
sales_channel.identifier in event.settings.payment_choice_postpone_allowed_channels and not payment_requests
)
)
if total != payments_assigned and not allow_postponed_payment:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1043,7 +1050,15 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
# Final calculation of fees, also performs final rounding
try:
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees = _apply_rounding_and_fees(
positions,
payment_requests,
address,
meta_info,
event,
sales_channel=sales_channel,
require_approval=require_approval
)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
+76
View File
@@ -0,0 +1,76 @@
#
# 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/>.
#
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from django.utils.timezone import make_aware, now
from pretix.base.models import Event, SalesChannel
from pretix.base.reldate import RelativeDateWrapper
def compute_payment_deadline(event: Event, sales_channel: SalesChannel, now_dt=None, subevents=None) -> datetime:
now_dt = now_dt or now()
tz = ZoneInfo(event.settings.timezone)
sales_channel_suffix = "_" + sales_channel.identifier.replace(".", "_")
if not (mode := event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(
days=event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if event.settings.get('payment_term_weekdays'):
if exp_by_date.weekday() == 5:
exp_by_date += timedelta(days=2)
elif exp_by_date.weekday() == 6:
exp_by_date += timedelta(days=1)
elif mode == 'minutes':
exp_by_date = now_dt.astimezone(tz) + timedelta(
minutes=event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
expires = exp_by_date
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return expires
term_last = min(terms)
else:
term_last = term_last.datetime(event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < expires:
return term_last
return expires
+68 -2
View File
@@ -343,6 +343,66 @@ def _validate_vat_id_EU(vat_id, country_code):
return vat_id
def _validate_vat_id_EU_fallback_germany(vat_id, country_code, requester_id):
# We can skip most static validation checks because _validate_vat_id_EU always runs before
vat_id = normalize_vat_id(vat_id, country_code)
# The VIES service of the European commission is overused and down due to rate limits A LOT. There is another
# API by German BZSt, but it only works if the requester is German and the requested is not.
# https://www.bzst.de/DE/Unternehmen/Identifikationsnummern/Umsatzsteuer-Identifikationsnummer/AuslaendischeUSt-IdNr/auslaendische_ust_idnr_node.html
try:
r = requests.post(
"https://api.evatr.vies.bzst.de/app/v1/abfrage",
json={
"anfragendeUstid": requester_id,
"angefragteUstid": vat_id,
},
timeout=10,
)
d = r.json()
if r.status_code == 200:
if d['status'] in ('evatr-0000', 'evatr-2008'):
# evatr-0000: Die angefragte Ust-IdNr. ist zum Anfragezeitpunkt gültig.
# evatr-2008: Die angefragte Ust-IdNr. ist zum Anfragezeitpunkt gültig.
# Für die qualifizierte Bestätigungsanfrage liegt einer Besonderheit vor.
# Für Rückfragen wenden Sie sich an das BZSt.
return vat_id
# evatr-2002: Die angefragte USt-IdNr. ist zum Anfragezeitpunkt nicht gültig.
# Sie ist erst gültig ab dem Datum im Feld gueltigAb.
# evatr-2006: Die angefragte Ust-IdNr. ist zum Anfragezeitpunkt nicht gültig.
# Sie war gültig im Zeitraum, der durch die Werte in den Feldern gueltigAb und gueltigBis beschrieben ist.
raise VATIDFinalError(error_messages['invalid'])
elif r.status_code == 400:
if d['status'] in ('evatr-0002', 'evatr-0004', 'evatr-0008'):
# evatr-0002: Mindestens eins der Pflichtfelder ist nicht besetzt.
# evatr-0004: Die anfragende DE Ust-IdNr. ist syntaktisch falsch. Sie passt nicht in das deutsche Erzeugungsschema.
# evatr-0008: Die maximale Anzahl von qualifizierten Bestätigungsabfragen für diese Session wurde erreicht.
# Bitte starten Sie erneut mit einer einfachen Bestätigungsabfrage.
raise VATIDTemporaryError(error_messages['unavailable'])
# evatr-0005: Die angegebene angefragte Ust-IdNr. ist syntaktisch falsch.
# evatr-0012: Die angefrage USt-IdNr. ist syntaktisch falsch. Sie passt nicht in das Erzeugungsschema.
# evatr-2003: Das angegebene Länderkennzeichen der angefragten USt-IdNr. ist nicht gültig.
raise VATIDFinalError(error_messages['invalid'])
elif r.status_code == 403:
# evatr-0006: Die anfragende DE USt-IdNr. ist nicht berechtigt eine DE Ust-IdNr. anzufragen.
# evatr-0007: Fehlerhafter Aufruf.
raise VATIDTemporaryError(error_messages['unavailable'])
elif r.status_code == 404:
if d['status'] in ('evatr-2005'):
# evatr-2005: Die angegebene eigene DE Ust-IdNr. ist zum Anfragezeitpunkt nicht gültig.
raise VATIDTemporaryError(error_messages['unavailable'])
# evatr-2001: Die angefragte USt-IdNr. ist zum Anfragezeitpunkt nicht vergeben.
raise VATIDFinalError(error_messages['invalid'])
else: # 500, 503
raise VATIDTemporaryError(error_messages['unavailable'])
except requests.RequestException:
logger.exception('VAT ID checking failed for country {}'.format(country_code))
raise VATIDTemporaryError(error_messages['unavailable'])
except ValueError: # JSON parsing failed
logger.exception('VAT ID checking failed for country {}'.format(country_code))
raise VATIDTemporaryError(error_messages['unavailable'])
def _validate_vat_id_CH(vat_id, country_code):
if vat_id[:3] != 'CHE':
raise VATIDFinalError(error_messages['country_mismatch'])
@@ -394,12 +454,18 @@ def _validate_vat_id_CH(vat_id, country_code):
return vat_id
def validate_vat_id(vat_id, country_code):
def validate_vat_id(vat_id, country_code, requester_id=None):
if not vat_id:
return vat_id
country_code = str(country_code)
if is_eu_country(country_code):
return _validate_vat_id_EU(vat_id, country_code)
try:
return _validate_vat_id_EU(vat_id, country_code)
except VATIDTemporaryError:
if requester_id and requester_id.startswith("DE") and not vat_id.startswith("DE"):
return _validate_vat_id_EU_fallback_germany(vat_id, country_code, requester_id)
else:
raise
elif country_code == 'CH':
return _validate_vat_id_CH(vat_id, country_code)
elif country_code == 'NO':
+23 -10
View File
@@ -1159,6 +1159,19 @@ DEFAULTS = {
"configured above."),
)
},
'payment_choice_postpone_allowed_channels': {
'default': [],
'type': list,
'form_class': forms.MultipleChoiceField,
'form_kwargs': dict(
label=_('Allow postponed payment choice for sales channels'),
help_text=_("If postponed payment is allowed on a sales channel, customers can complete their order without "
"selecting a payment method. This is useful whenever orders are not created by the same "
"person who is making the payment."),
widget=forms.CheckboxSelectMultiple,
choices=[],
)
},
'presale_start_show_date': {
'default': 'True',
'type': bool,
@@ -1930,8 +1943,6 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Hide all unavailable dates from calendar or list views"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
)
},
'event_calendar_future_only': {
@@ -2303,25 +2314,27 @@ DEFAULTS = {
},
'contact_url': {
'default': None,
'type': str,
'serializer_class': serializers.URLField,
'form_class': forms.URLField,
'type': LazyI18nString,
'form_class': I18nURLFormField,
'form_kwargs': dict(
label=_("Contact URL"),
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
"Please note that you still need to add a contact email address that will be shared with all emails you send."),
widget=I18nTextInput,
),
'serializer_class': I18nURLField,
},
'imprint_url': {
'default': None,
'type': str,
'form_class': forms.URLField,
'type': LazyI18nString,
'form_class': I18nURLFormField,
'form_kwargs': dict(
label=_("Imprint URL"),
help_text=_("This should point e.g. to a part of your website that has your contact details and legal "
"information."),
widget=I18nTextInput,
),
'serializer_class': serializers.URLField,
'serializer_class': I18nURLField,
},
'privacy_url': {
'default': None,
+2
View File
@@ -135,6 +135,8 @@ class BaseQuestionsViewMixin:
question_field.initial = getattr(question_field, 'initial', None) or src['initial']
if 'validators' in src:
question_field.validators += src['validators']
if 'label' in src:
question_field.label = src['label']
if len(form.fields) > 0:
formlist.append(form)
+12 -1
View File
@@ -172,7 +172,9 @@ class CachedFileInput(forms.ClearableFileInput):
from ...base.models import CachedFile
v = super().value_from_datadict(data, files, name)
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
v = CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
if not v.allowed_for_session(self.request):
v = None
return v
def get_context(self, name, value, attrs):
@@ -244,6 +246,11 @@ class ExtFileField(ExtValidationMixin, SizeFileField):
class CachedFileField(ExtFileField):
widget = CachedFileInput
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)
self.widget.request = self.request
def to_python(self, data):
from ...base.models import CachedFile
@@ -271,6 +278,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
@@ -294,6 +303,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
+8 -1
View File
@@ -855,14 +855,21 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
'payment_term_accept_late',
'payment_pending_hidden',
'payment_explanation',
'payment_choice_postpone_allowed_channels',
'tax_rule_payment',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
channels = list(self.obj.organizer.sales_channels.all())
self.fields['payment_choice_postpone_allowed_channels'].choices = [
(c.identifier, c.label) for c in channels
if c.type_instance.payment_restrictions_supported
]
self.term_channel_fields = {}
for c in self.obj.organizer.sales_channels.all():
for c in channels:
if c.type_instance.payment_restrictions_supported and c.identifier != "web":
# At the moment, it seems sufficient to allow this for the same channel types as other payment settings
# We can always introduce more flags later if needed
+2
View File
@@ -87,6 +87,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
@@ -134,6 +135,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
@@ -109,6 +109,7 @@
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.tax_rule_payment layout="control" %}
{% bootstrap_field form.payment_explanation layout="control" %}
{% bootstrap_field form.payment_choice_postpone_allowed_channels layout="control" %}
</fieldset>
</div>
{% if "event.settings.payment:write" in request.eventpermset %}
+12 -9
View File
@@ -64,6 +64,7 @@ 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 import OF_SELF
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
@@ -395,15 +396,17 @@ class Recover(TemplateView):
def post(self, request, *args, **kwargs):
if self.form.is_valid():
try:
user = User.objects.get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
with transaction.atomic():
# Check token in transaction to prevent race condition
try:
user = User.objects.select_for_update(of=OF_SELF).get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
+2 -1
View File
@@ -1646,7 +1646,8 @@ class OrderCheckVATID(OrderView):
return redirect(self.get_order_url())
try:
normalized_id = validate_vat_id(ia.vat_id, str(ia.country))
requester_id = self.request.event.settings.invoice_address_from_vat_id
normalized_id = validate_vat_id(ia.vat_id, str(ia.country), requester_id)
with transaction.atomic():
ia.vat_id_validated = True
ia.vat_id = normalized_id
+5
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from io import BytesIO
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -193,6 +194,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.save()
c.file.save('empty.pdf', ContentFile(buffer.read()))
@@ -218,6 +220,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.file = fileobj
c.save()
@@ -303,5 +306,7 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
if not cf.allowed_for_session(request, "ticketoutput-pdf-background"):
raise PermissionDenied()
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp
+3 -1
View File
@@ -494,6 +494,7 @@ def webhook(request, *args, **kwargs):
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
if sale['status'] == 'COMPLETED':
any_captures = False
all_captures_completed = True
any_pending_review = False
any_failed = None
@@ -505,6 +506,7 @@ def webhook(request, *args, **kwargs):
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
any_captures = True
if capture['status'] in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
pass
elif capture['status'] in ("DECLINED", "FAILED"):
@@ -516,7 +518,7 @@ def webhook(request, *args, **kwargs):
any_pending_review = True
else:
raise ValueError("Unknown paypal capture state: {}".format(capture['status']))
if all_captures_completed:
if any_captures and all_captures_completed:
try:
payment.confirm()
prov.log_payment_duration(payment)
+11 -8
View File
@@ -56,18 +56,11 @@ from pretix.base.services.placeholders import FormPlaceholderMixin # noqa
class BaseMailForm(FormPlaceholderMixin, forms.Form):
subject = forms.CharField(label=_("Subject"))
message = forms.CharField(label=_("Message"))
attachment = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_('Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT
)
def __init__(self, *args, **kwargs):
event = self.event = kwargs.pop('event')
context_parameters = kwargs.pop('context_parameters')
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['subject'] = I18nFormField(
label=_('Subject'),
@@ -79,6 +72,16 @@ class BaseMailForm(FormPlaceholderMixin, forms.Form):
widget=I18nMarkdownTextarea, required=True,
locales=event.settings.get('locales'),
)
self.fields['attachment'] = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_(
'Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT,
request=request,
)
self._set_field_placeholders('subject', context_parameters, rich=False)
self._set_field_placeholders('message', context_parameters, rich=True)
+1
View File
@@ -157,6 +157,7 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['context_parameters'] = self.context_parameters
kwargs['request'] = self.request
if 'from_log' in self.request.GET:
try:
from_log_id = self.request.GET.get('from_log')
+58 -3
View File
@@ -35,6 +35,7 @@ import copy
import inspect
import uuid
from collections import defaultdict
from datetime import time
from decimal import Decimal
from django import forms
@@ -52,6 +53,7 @@ from django.shortcuts import redirect
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
)
@@ -71,6 +73,7 @@ from pretix.base.services.cart import (
from pretix.base.services.cross_selling import CrossSellingService
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
from pretix.base.services.payment import compute_payment_deadline
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import EventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -840,6 +843,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
f.fields[fname].disabled = val['disabled']
if 'validators' in val and fname in f.fields:
f.fields[fname].validators += val['validators']
if 'label' in val and fname in f.fields:
f.fields[fname].label = val['label']
return f
@@ -868,6 +873,28 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
'attendee_name_parts': d
})
wd = self.cart_session.get('widget_data', {})
if wd.get('attendee-fix', '') == 'true':
for k, v in wd.items():
if v and k.startswith('attendee-name'):
o.append({
'attendee_name_parts': {
'disabled': True,
}
})
elif v and k.startswith('email'):
o.append({
'attendee_email': {
'disabled': True,
}
})
elif v and k.startswith('question-'):
o.append({
k[9:].upper(): {
'disabled': True,
}
})
return o
@cached_property
@@ -944,6 +971,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
f.fields[fname].disabled = val['disabled']
if 'validators' in val and fname in f.fields:
f.fields[fname].validators += val['validators']
if 'label' in val and fname in f.fields:
f.fields[fname].label = val['label']
return f
@@ -1340,6 +1369,11 @@ class PaymentStep(CartMixin, TemplateFlowStep):
self.request = request
self.request.pci_dss_payment_page = True
if "postpone" in request.POST and self._allow_postpone:
self.cart_session['payments_postpone'] = True
self.cart_session['payments'] = []
return redirect_to_url(self.get_next_url(request))
if "remove_payment" in request.POST:
self._remove_payment(request.POST["remove_payment"])
return redirect_to_url(self.get_step_url(request))
@@ -1428,20 +1462,41 @@ class PaymentStep(CartMixin, TemplateFlowStep):
ctx['providers'] = self.provider_forms
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
if len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif 'payment' in self.request.POST:
if 'payment' in self.request.POST:
ctx['selected'] = self.request.POST['payment']
elif self.cart_session.get('payments_postpone') and self._allow_postpone:
ctx['selected'] = ''
elif len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif self.single_use_payment:
ctx['selected'] = self.single_use_payment['provider']
else:
ctx['selected'] = ''
ctx['allow_postpone'] = self._allow_postpone
if self._allow_postpone:
now_dt = now()
ctx['payment_deadline'] = compute_payment_deadline(
event=self.request.event,
sales_channel=self.request.sales_channel,
subevents={p.subevent for p in ctx['cart']['raw']},
now_dt=now_dt,
)
if ctx['payment_deadline'].time() != time(hour=23, minute=59, second=59):
ctx['payment_deadline_minutes'] = int((ctx['payment_deadline'] - now_dt).total_seconds() // 60)
return ctx
@cached_property
def _allow_postpone(self):
return self.request.sales_channel.identifier in self.request.event.settings.payment_choice_postpone_allowed_channels
def _is_allowed(self, prov, request):
return prov.is_allowed(request, total=self._total_order_value)
def is_completed(self, request, warn=False):
if self.cart_session.get('payments_postpone') and self._allow_postpone:
return True
if not self.cart_session.get('payments'):
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
+1 -1
View File
@@ -334,7 +334,7 @@ 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):
if rate_limit("customer_pwreset_check", include_ip_from_request=self.request, max_num=10, expire_time=600):
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
+4 -4
View File
@@ -233,7 +233,7 @@ Arguments: ``request``, ``order``
This signal allows you to override fields of the contact form that is presented during checkout
and by default only asks for the email address. It is also being used for the invoice address
form. You are supposed to return a dictionary of dictionaries with globally unique keys. The
value-dictionary should contain one or more of the following keys: ``initial``, ``disabled``,
value-dictionary should contain one or more of the following keys: ``label``, ``initial``, ``disabled``,
``validators``. The key of the dictionary should be the name of the form field.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. A ``request``
@@ -264,9 +264,9 @@ Arguments: ``position``, ``request``
This signal allows you to override fields of the questions form that is presented during checkout
and by default only asks for the questions configured in the backend. You are supposed to return a
dictionary of dictionaries with globally unique keys. The value-dictionary should contain one or
more of the following keys: ``initial``, ``disabled``, ``validators``. The key of the dictionary
should be the form field name for system fields (e.g. ``company``), or the question's ``identifier``
for user-defined questions.
more of the following keys: ``label``, ``initial``, ``disabled``, ``validators``. The key of the
dictionary should be the form field name for system fields (e.g. ``company``), or the question's
``identifier`` for user-defined questions.
The ``position`` keyword argument will contain a ``CartPosition`` or ``OrderPosition`` object.
@@ -128,6 +128,35 @@
{% endif %}
</div>
{% endif %}
{% if allow_postpone %}
<div class="panel panel-default">
<div class="panel-body row">
<div class="col-md-9 col-xs-12">
{% trans "Not sure yet? You can complete your order first and then select a payment method later." %}
<br>
<span class="text-muted">
{% if current_payments %}
{% trans "To do so, please first remove the payment methods you already selected above." %}
{% elif payment_deadline_minutes %}
{% blocktrans trimmed with minutes=payment_deadline_minutes %}
Your payment needs to be completed within {{ minutes }} minutes.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with deadline=payment_deadline|date:"SHORT_DATE_FORMAT" %}
Your payment needs to be completed by {{ deadline }}.
{% endblocktrans %}
{% endif %}
</span>
</div>
<div class="col-md-3 col-xs-12 text-right flip">
<button name="postpone" value="on" class="btn btn-primary"
{% if current_payments %}disabled{% endif %}>
{% trans "Proceed without selection" %}
</button>
</div>
</div>
</div>
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
+6
View File
@@ -54,6 +54,7 @@ from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition
from pretix.base.services.mail import mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import customer_created, customer_signed_in
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.models import KnownDomain
@@ -280,6 +281,11 @@ class SetPasswordView(FormView):
def form_valid(self, form):
with transaction.atomic():
# Re-check token in transaction to prevent race condition
self.customer = Customer.objects.select_for_update(of=OF_SELF).get(pk=self.customer.pk)
if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')):
return HttpResponseRedirect(self.get_success_url())
self.customer.set_password(form.cleaned_data['password'])
self.customer.is_verified = True
self.customer.save()
+5 -7
View File
@@ -79,7 +79,7 @@ from pretix.presale.signals import seatingframe_html_head
from pretix.presale.views.organizer import (
EventListMixin, add_subevents_for_days, days_for_template,
filter_qs_by_attr, filter_subevents_with_plugins, has_before_after,
weeks_for_template,
should_hide_subevent, weeks_for_template,
)
from . import (
@@ -443,12 +443,10 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
)
)
subevents = filter_subevents_with_plugins(list(subevents), self.request.sales_channel)
context['subevent_list'] = subevents
if self.request.event.settings.event_list_available_only and not voucher:
context['subevent_list'] = [
se for se in subevents
if not se.presale_has_ended and (se.best_availability_state is None or se.best_availability_state >= Quota.AVAILABILITY_RESERVED)
]
context['subevent_list'] = [
se for se in subevents
if not should_hide_subevent(self.request.event.settings, se, voucher)
]
context['visible_events'] = len(subevents) > 0
return context
+37 -27
View File
@@ -46,7 +46,7 @@ import isoweek
from django.conf import settings
from django.core.cache import caches
from django.db.models import (
Case, Exists, F, Max, Min, OuterRef, Prefetch, Q, Subquery, Value, When,
Case, Exists, F, Max, Min, OuterRef, Prefetch, Q, Value, When,
)
from django.db.models.functions import Coalesce, Greatest
from django.dispatch.dispatcher import NO_RECEIVERS
@@ -189,27 +189,22 @@ class EventListMixin:
def _get_event_list_queryset(self):
query = Q(is_public=True) & Q(live=True)
qs = self.request.organizer.events.using(settings.DATABASE_REPLICA).filter(query)
qs = qs.filter(Q(all_sales_channels=True) | Q(id__in=self.request.sales_channel.event_set.values_list("pk")))
qs = qs.filter(Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel))
show_old = "old" in self.request.GET
subevent_filter = Q(active=True, is_public=True)
subevent_filter = Q(subevents__active=True, subevents__is_public=True)
if not show_old:
subevent_filter &= Q(
Q(date_to__gte=now()) | Q(date_from__gte=now())
Q(subevents__date_to__gte=now()) | Q(subevents__date_from__gte=now())
)
subevent_subquery_qs = SubEvent.objects.with_scopes_disabled().filter(
subevent_filter,
event_id=OuterRef('pk')
).values('event').order_by()
qs = qs.annotate(
min_from=Subquery(subevent_subquery_qs.annotate(m=Min('date_from')).values('m')),
min_to=Subquery(subevent_subquery_qs.annotate(m=Min('date_to')).values('m')),
max_from=Subquery(subevent_subquery_qs.annotate(m=Max('date_from')).values('m')),
max_to=Subquery(subevent_subquery_qs.annotate(m=Max('date_to')).values('m')),
).annotate(
max_fromto=Greatest(F("max_to"), F("max_from")),
min_from=Min('subevents__date_from', filter=subevent_filter),
min_to=Min('subevents__date_to', filter=subevent_filter),
max_from=Max('subevents__date_from', filter=subevent_filter),
max_to=Max('subevents__date_to', filter=subevent_filter),
max_fromto=Greatest(Max('subevents__date_to', filter=subevent_filter), Max('subevents__date_from', filter=subevent_filter)),
)
if show_old:
date_q = Q(date_to__lt=now()) | (Q(date_to__isnull=True) & Q(date_from__lt=now()))
@@ -606,6 +601,32 @@ def filter_subevents_with_plugins(subevents, sales_channel=None):
return subevents
def should_hide_subevent(settings, subevent, voucher=None):
hide = False
if settings.event_list_available_only:
hide = (
# Presale is over → the subevent is not available → hide
subevent.presale_has_ended or
# Not a single product is available on this sales channel → hide
# Note that means there could be products which are ignored for calendar availability (Quota.ignore_for_event_availability)
# or products only visible with a voucher. However, for customers with these scenarios, the event_list_available_only
# makes only very little sense as it would never do anything, so the flag can just be removed -- or the products should
# be made visible so people know why there are no products. In case a voucher is already entered on the calendar view,
# this is already respected and subevents are shown correctly.
subevent.best_availability_state is None or
(
# Sold out → hide, unless we have a voucher active that can bypass all quotas
(not voucher or not voucher.allow_ignore_quota) and
subevent.best_availability_state < Quota.AVAILABILITY_RESERVED
)
)
if settings.event_calendar_future_only:
if (subevent.date_to or subevent.date_from) < time_machine_now():
hide = True
return hide
def add_subevents_for_days(qs, before, after, ebd, timezones, sales_channel, event=None, cart_namespace=None,
voucher=None):
qs = qs.filter(active=True, is_public=True).filter(
@@ -645,19 +666,8 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, sales_channel, eve
kwargs['cart_namespace'] = cart_namespace
s = event.settings if event else se.event.settings
if s.event_list_available_only:
hide = se.presale_has_ended or (
(not voucher or not voucher.allow_ignore_quota) and
se.best_availability_state is not None and
se.best_availability_state < Quota.AVAILABILITY_RESERVED
)
if hide:
continue
if s.event_calendar_future_only:
if (se.date_to or se.date_from) < time_machine_now():
continue
if should_hide_subevent(s, se, voucher):
continue
timezones.add(s.timezone)
tz = ZoneInfo(s.timezone)
+5 -9
View File
@@ -75,7 +75,7 @@ from pretix.presale.views.cart import get_or_create_cart_id
from pretix.presale.views.organizer import (
EventListMixin, add_events_for_days, add_subevents_for_days,
days_for_template, filter_qs_by_attr, filter_subevents_with_plugins,
weeks_for_template,
should_hide_subevent, weeks_for_template,
)
logger = logging.getLogger(__name__)
@@ -757,14 +757,10 @@ class WidgetAPIProductList(EventListMixin, View):
evs = evs[:limit]
tz = request.event.timezone
if self.request.event.settings.event_list_available_only:
evs = [
se for se in evs
if not se.presale_has_ended and (
se.best_availability_state is not None and
se.best_availability_state >= Quota.AVAILABILITY_RESERVED
)
]
evs = [
se for se in evs
if not should_hide_subevent(self.request.event.settings, se)
]
data['events'] = [
{
@@ -170,13 +170,14 @@ body.has-modal-dialog .container, body.has-modal-dialog #wrapper {
#lightbox-dialog {
width: fit-content;
max-width: 80%;
min-width: 24em;
min-width: calc(min(24em, 90%));
.modal-card-content {
padding: 2.5em;
}
img {
max-width: 100%;
max-height: calc(100dvh - 60px - 5em - 5em);
}
button {
@@ -345,7 +345,7 @@ Vue.component('pricebox', {
+ ' :min="display_price_nonlocalized" :value="suggested_price_nonlocalized" :name="field_name"'
+ ' step="any" v-bind:aria-labelledby="aria_labelledby" v-bind:aria-describedby="price_desc_id">'
+ '</div>'
+ '<small class="pretix-widget-pricebox-tax" :id="price_desc_id" v-if="price.rate != \'0\' && price.gross != \'0.00\'">'
+ '<small class="pretix-widget-pricebox-tax" :id="price_desc_id" v-if="show_taxline">'
+ '{{ taxline }}'
+ '</small>'
+ '</div>'),
@@ -422,6 +422,10 @@ Vue.component('pricebox', {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + this.display_price;
}
},
show_taxline: function () {
// rate can either be "0.00" or "0" => parseFloat to check
return Number.parseFloat(this.price.rate) && Number.parseFloat(this.price.gross);
},
taxline: function () {
if (this.$root.display_net_prices) {
if (this.price.includes_mixed_tax_rate) {
@@ -86,7 +86,8 @@ const taxline = computed(() => {
}
})
const showTaxline = computed(() => props.price.rate !== '0' && props.price.gross !== '0.00')
// rate can either be "0.00" or "0" => parseFloat to check
const showTaxline = computed(() => Number.parseFloat(props.price.rate) && Number.parseFloat(props.price.gross))
</script>
<template lang="pug">
.pretix-widget-pricebox
+21 -7
View File
@@ -1434,8 +1434,12 @@ def test_get_event_settings(token_client, organizer, event):
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
)
assert resp.status_code == 200
assert resp.data['imprint_url'] == "https://example.org"
assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['imprint_url'] == {
"en": "https://example.org",
}
assert resp.data['contact_url'] == {
"en": "https://example.org/contact",
}
assert resp.data['seating_allow_blocked_seats_for_channel'] == []
resp = token_client.get(
@@ -1443,7 +1447,9 @@ def test_get_event_settings(token_client, organizer, event):
)
assert resp.status_code == 200
assert resp.data['imprint_url'] == {
"value": "https://example.org",
"value": {
"en": "https://example.org",
},
"label": "Imprint URL",
"help_text": "This should point e.g. to a part of your website that has your contact details and legal "
"information.",
@@ -1478,8 +1484,12 @@ def test_patch_event_settings(token_client, organizer, event, team):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == "https://example.com/contact"
assert resp.data['imprint_url'] == "https://example.com"
assert resp.data['contact_url'] == {
"en": "https://example.com/contact",
}
assert resp.data['imprint_url'] == {
"en": "https://example.com",
}
assert resp.data['seating_allow_blocked_seats_for_channel'] == ['web']
assert not resp.data['reusable_media_active']
event.settings.flush()
@@ -1542,8 +1552,12 @@ def test_patch_event_settings(token_client, organizer, event, team):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['imprint_url'] == "https://example.org"
assert resp.data['contact_url'] == {
"en": "https://example.org/contact",
}
assert resp.data['imprint_url'] == {
"en": "https://example.org",
}
event.settings.flush()
assert event.settings.contact_url == 'https://example.org/contact'
assert event.settings.imprint_url == 'https://example.org'
+5 -2
View File
@@ -25,6 +25,7 @@ from datetime import datetime
import pytest
from django.core.files.base import ContentFile
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from tests.const import SAMPLE_PNG
TEST_ORGANIZER_RES = {
@@ -165,9 +166,11 @@ def test_patch_settings(token_client, organizer):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == 'https://example.org/contact'
assert resp.data['contact_url'] == {
'en': 'https://example.org/contact',
}
organizer.settings.flush()
assert organizer.settings.contact_url == 'https://example.org/contact'
assert organizer.settings.contact_url == LazyI18nString('https://example.org/contact')
resp = token_client.patch(
'/api/v1/organizers/{}/settings/'.format(organizer.slug),
+118
View File
@@ -299,6 +299,30 @@ def get_test_order_review_pending():
'method': 'GET'}]}
def get_test_empty_captures():
return {'id': '806440346Y391300T',
'intent': 'CAPTURE',
'status': 'COMPLETED',
'purchase_units': [{'reference_id': 'default',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'payee': {'email_address': 'dummy-facilitator@dummy.dummy',
'merchant_id': 'G6R2B9YXADKWW'},
'description': 'Order JWJGC for PayPal v2',
'custom_id': 'Order PAYPALV2-JWJGC',
'soft_descriptor': 'MARTINFACIL',
'payments': {'captures': []}
}],
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
'email_address': 'dummy@dummy.dummy',
'payer_id': 'Q739JNKWH67HE',
'address': {'country_code': 'DE'}},
'create_time': '2022-04-28T11:59:59Z',
'update_time': '2022-04-28T12:00:22Z',
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'self',
'method': 'GET'}]}
class Object():
pass
@@ -456,6 +480,100 @@ def test_webhook_all_good(env, client, monkeypatch):
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_empty_captures(env, client, monkeypatch):
order = env[1]
with scopes_disabled():
p = order.payments.first()
p.state = OrderPayment.PAYMENT_STATE_PENDING
p.save()
order.status = Order.STATUS_PENDING
order.save()
pp_order = Result(get_test_empty_captures())
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-4T867178D0574904F-7TT11736YU643990P",
"create_time": "2022-04-28T12:00:37.077Z",
"resource_type": "checkout-order",
"event_type": "CHECKOUT.ORDER.COMPLETED",
"summary": "Checkout Order Completed",
"resource": {
"update_time": "2022-04-28T12:00:22Z",
"create_time": "2022-04-28T11:59:59Z",
"purchase_units": [
{
"reference_id": "default",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"payee": {
"email_address": "dummy-facilitator@dummy.dummy",
"merchant_id": "G6R2B9YXADKWW"
},
"description": "Order JWJGC for PayPal v2",
"custom_id": "Order PAYPALV2-JWJGC",
"soft_descriptor": "MARTINFACIL",
"payments": {
"captures": []
}
}
],
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"rel": "self",
"method": "GET"
}
],
"id": "806440346Y391300T",
"intent": "CAPTURE",
"payer": {
"name": {
"given_name": "test",
"surname": "buyer"
},
"email_address": "dummy@dummy.dummy",
"payer_id": "Q739JNKWH67HE",
"address": {
"country_code": "DE"
}
},
"status": "COMPLETED"
},
"status": "SUCCESS",
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P",
"rel": "self",
"method": "GET",
"encType": "application/json"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P/resend",
"rel": "resend",
"method": "POST",
"encType": "application/json"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PENDING
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
+91
View File
@@ -2372,6 +2372,97 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
assert p2.fee.value == Decimal("0.46")
assert o.total == Decimal("25.76")
def test_payment_postpone_not_allowed(self):
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=False)
assert 'Please select' in response.content.decode()
def test_payment_postpone_allowed(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert not o.payments.exists()
def test_payment_postpone_cleared_on_selection(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=False)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert self.client.session['carts'][self.session_key].get('payments_postpone')
# The only available provider must not be preselected while the choice is postponed
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="payment"]')), 1)
self.assertEqual(len(doc.select('input[name="payment"][checked]')), 0)
# Selecting a payment method takes the order out of the postponed state again
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer',
}, follow=False)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert not self.client.session['carts'][self.session_key].get('payments_postpone')
def test_payment_postpone_disabled_with_partial_payment(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20, acceptor=self.orga)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('button[name="postpone"]')), 1)
self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 0)
# Apply a gift card that only covers part of the total
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'payment_giftcard-code': gc.secret,
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
# Postponing would silently drop the gift card, so it is no longer offered
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 1)
def test_premature_confirm(self):
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),
+3
View File
@@ -31,6 +31,9 @@ export default defineConfig({
// Allow serving source files from sibling plugin directories
allow: ['src', ...pluginDirs],
},
cors: {
origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\]|[^:]+\.pretix\.(dev|work))(?::\d+)?$/
},
},
build: {
manifest: true,