mirror of
https://github.com/pretix/pretix.git
synced 2026-09-17 16:54:42 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dedd548cf7 | ||
|
|
edb4069e18 |
@@ -1,244 +0,0 @@
|
||||
Event Meta Properties
|
||||
=====================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
An event meta property is used to to define meta information fields for its events.
|
||||
This information can be re-used, for example, in ticket layouts.
|
||||
|
||||
The event meta property resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Unique ID for this property
|
||||
name string Name of the property
|
||||
default string Value of the default option
|
||||
required boolean If ``true``, an event can only be taken live if the
|
||||
property is set. In event series, it's always optional
|
||||
to set a value for individual dates
|
||||
protected boolean If ``true``, the value for an event can only be changed
|
||||
by organizer-level administrators
|
||||
filter_public boolean If ``true``, this property will be shown to filter
|
||||
events in the public event list and calendar
|
||||
public_label string Public name of the property
|
||||
filter_allowed boolean If ``true``, this property will be shown to filter
|
||||
events or reports in the backend, and it can also be
|
||||
used for hidden filter parameters in the frontend
|
||||
choices list of objects List of JSON objects representing all permitted values
|
||||
for this property, or ``null`` for no limitation.
|
||||
Each choice object has a required internal name named
|
||||
``key`` and optional public name named ``label``
|
||||
consisting of a dictionary of i18n string translations,
|
||||
as well as other implementation based key-value-pairs
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/
|
||||
|
||||
Returns a list of all meta properties for the organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/meta_properties/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Color",
|
||||
"default": "blue",
|
||||
"required": false,
|
||||
"protected": false,
|
||||
"filter_public": false,
|
||||
"public_label": {},
|
||||
"filter_allowed": true,
|
||||
"choices": [
|
||||
{
|
||||
"key": "blue",
|
||||
"ORDER": 1,
|
||||
"label": {
|
||||
"en": "Blue"
|
||||
},
|
||||
"DELETE": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
|
||||
|
||||
Returns information on one property, identified by its id.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Color",
|
||||
"default": "blue",
|
||||
"required": false,
|
||||
"protected": false,
|
||||
"filter_public": false,
|
||||
"public_label": {},
|
||||
"filter_allowed": true,
|
||||
"choices": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer
|
||||
:param id: The ``id`` field of the meta property to retrieve
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/event_meta_properties/
|
||||
|
||||
Creates a new meta property
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/event_meta_properties/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "ref-code",
|
||||
"default": "abcde",
|
||||
"required": true,
|
||||
"choices": null
|
||||
}
|
||||
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"name": "reference",
|
||||
"default": "abcde",
|
||||
"required": true,
|
||||
"protected": false,
|
||||
"filter_public": false,
|
||||
"public_label": null,
|
||||
"filter_allowed": true,
|
||||
"choices": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The meta property could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
|
||||
|
||||
Update a meta property. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide
|
||||
all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the
|
||||
fields that you want to change.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/event_meta_properties/2/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"required": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"name": "reference",
|
||||
"default": "abcde",
|
||||
"required": false,
|
||||
"protected": false,
|
||||
"filter_public": false,
|
||||
"public_label": null,
|
||||
"filter_allowed": true,
|
||||
"choices": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer
|
||||
:param id: The ``id`` field of the meta property to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The property could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/event_meta_properties/(id)/
|
||||
|
||||
Delete a meta property.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/event_meta_properties/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer
|
||||
:param id: The ``id`` field of the meta property to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
|
||||
@@ -12,7 +12,6 @@ at :ref:`plugin-docs`.
|
||||
organizers
|
||||
events
|
||||
subevents
|
||||
event_meta_properties
|
||||
taxrules
|
||||
categories
|
||||
items
|
||||
|
||||
@@ -40,10 +40,9 @@ from pretix.api.serializers.settings import SettingsSerializer
|
||||
from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, GiftCard, GiftCardAcceptance,
|
||||
GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer,
|
||||
ReusableMedium, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite,
|
||||
User,
|
||||
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
|
||||
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
|
||||
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||
from pretix.base.permissions import (
|
||||
@@ -641,12 +640,3 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
)
|
||||
# TODO: make sure pub is always correct
|
||||
return 'pub/' + fname
|
||||
|
||||
|
||||
class EventMetaPropertiesSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = EventMetaProperty
|
||||
fields = (
|
||||
'id', 'name', 'default', 'required', 'protected', 'filter_public', 'public_label', 'filter_allowed',
|
||||
'choices'
|
||||
)
|
||||
|
||||
@@ -68,7 +68,6 @@ orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportVie
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
|
||||
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
|
||||
orga_router.register(r'event_meta_properties', organizer.EventMetaPropertiesViewSet)
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
|
||||
@@ -44,16 +44,15 @@ from pretix.api.models import OAuthAccessToken
|
||||
from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.organizer import (
|
||||
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
|
||||
EventMetaPropertiesSerializer, GiftCardSerializer,
|
||||
GiftCardTransactionSerializer, MembershipSerializer,
|
||||
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
|
||||
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
|
||||
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
|
||||
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, Device, Event, EventMetaProperty, GiftCard, GiftCardTransaction,
|
||||
LogEntry, Membership, MembershipType, Organizer, SalesChannel, SeatingPlan,
|
||||
Team, TeamAPIToken, TeamInvite, User,
|
||||
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
|
||||
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
|
||||
TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.plugins import (
|
||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||
@@ -847,50 +846,3 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
|
||||
class EventMetaPropertiesViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = EventMetaPropertiesSerializer
|
||||
queryset = EventMetaProperty.objects.none()
|
||||
write_permission = 'organizer.settings.general:write'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = EventMetaProperty.objects.all()
|
||||
return qs
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.property.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'id': instance.pk}
|
||||
)
|
||||
instance.delete()
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer_id=self.request.organizer.pk)
|
||||
serializer.instance.log_action(
|
||||
'pretix.property.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
return inst
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save(organizer_id=self.request.organizer.pk)
|
||||
serializer.instance.log_action(
|
||||
'pretix.property.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
return inst
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -717,10 +717,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
||||
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
|
||||
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
|
||||
'pretix.property.created': _('An organizer meta property has been created.'),
|
||||
'pretix.property.deleted': _('An organizer meta property has been deleted.'),
|
||||
'pretix.property.changed': _('An organizer meta property has been changed.'),
|
||||
'pretix.property.reordered': _('An organizer meta property has been reordered.'),
|
||||
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
@@ -1344,6 +1347,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))
|
||||
@@ -1432,20 +1440,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.'))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -171,12 +171,28 @@ body.has-modal-dialog .container, body.has-modal-dialog #wrapper {
|
||||
width: fit-content;
|
||||
max-width: 80%;
|
||||
min-width: 24em;
|
||||
max-height: 80vh;
|
||||
margin-top: 10vh;
|
||||
|
||||
.modal-card-content {
|
||||
padding: 2.5em;
|
||||
// not symmetric, but LOOKS more symmetric since the picture has so much more optical weight than the caption
|
||||
padding: 2.5em 2.5em 1.5em 2.5em;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(80vh - 4em);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
flex-basis: 10%;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user