Customer accounts & Memberships (#2024)

This commit is contained in:
Raphael Michel
2021-05-04 16:56:06 +02:00
committed by GitHub
parent 62e412bbc0
commit 8e79eb570e
116 changed files with 7975 additions and 279 deletions

View File

@@ -66,7 +66,8 @@ from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Event, Invoice, InvoiceAddress, Order, OrderPosition, User,
CachedFile, Customer, Event, Invoice, InvoiceAddress, Order, OrderPosition,
Organizer, User,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask
@@ -92,10 +93,10 @@ class SendMailException(Exception):
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None,
order: Order = None, position: OrderPosition = None, headers: dict = None, sender: str = None,
invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, attach_ical=False,
attach_cached_files: Sequence = None):
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -113,6 +114,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param event: The event this email is related to (optional). If set, this will be used to determine the sender,
a possible prefix for the subject and the SMTP server that should be used to send this email.
:param organizer: The event this organizer is related to (optional). If set, this will be used to determine the sender,
a possible prefix for the subject and the SMTP server that should be used to send this email.
:param order: The order this email is related to (optional). If set, this will be used to include a link to the
order below the email.
@@ -136,6 +140,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param user: The user this email is sent to
:param customer: The user this email is sent to
:param attach_cached_files: A list of cached file to attach to this email.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
@@ -165,15 +171,24 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
'invoice_name': '',
'invoice_company': ''
})
renderer = ClassicMailRenderer(None)
renderer = ClassicMailRenderer(None, organizer)
content_plain = body_plain = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) or settings.MAIL_FROM
sender = (
sender or
(event.settings.get('mail_from') if event else settings.MAIL_FROM) or
(organizer.settings.get('mail_from') if organizer else settings.MAIL_FROM) or
settings.MAIL_FROM
)
if event:
sender_name = str(event.name)
sender_name = event.settings.mail_from_name or str(event.name)
if len(sender_name) > 75:
sender_name = sender_name[:75] + "..."
sender = formataddr((sender_name, sender))
elif organizer:
sender_name = organizer.settings.mail_from_name or str(organizer.name)
if len(sender_name) > 75:
sender_name = sender_name[:75] + "..."
sender_name = event.settings.mail_from_name or sender_name
sender = formataddr((sender_name, sender))
else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
@@ -182,17 +197,27 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
signature = ""
bcc = []
settings_holder = event or organizer
if event:
timezone = event.timezone
renderer = event.get_html_mail_renderer()
if event.settings.mail_bcc:
for bcc_mail in event.settings.mail_bcc.split(','):
elif user:
timezone = pytz.timezone(user.timezone)
elif organizer:
timezone = organizer.timezone
else:
timezone = pytz.timezone(settings.TIME_ZONE)
if settings_holder:
if settings_holder.settings.mail_bcc:
for bcc_mail in set.settings.mail_bcc.split(','):
bcc.append(bcc_mail.strip())
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = event.settings.contact_mail
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail
prefix = event.settings.get('mail_prefix')
prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
@@ -200,12 +225,15 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
body_plain += "\r\n\r\n-- \r\n"
signature = str(event.settings.get('mail_text_signature'))
signature = str(settings_holder.settings.get('mail_text_signature'))
if signature:
signature = signature.format(event=event.name)
signature = signature.format(event=event.name if event else '')
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
if event:
renderer = event.get_html_mail_renderer()
if order and order.testmode:
subject = "[TESTMODE] " + subject
@@ -242,10 +270,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
)
)
body_plain += "\r\n"
elif user:
timezone = pytz.timezone(user.timezone)
else:
timezone = pytz.timezone(settings.TIME_ZONE)
with override(timezone):
try:
@@ -276,6 +300,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
attach_tickets=attach_tickets,
attach_ical=attach_ical,
user=user.pk if user else None,
organizer=organizer.pk if organizer else None,
customer=customer.pk if customer else None,
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
)
@@ -314,7 +340,7 @@ class CustomEmail(EmailMultiAlternatives):
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
attach_ical=False, attach_cached_files: List[int] = None) -> bool:
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
@@ -326,15 +352,25 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
if user:
user = User.objects.get(pk=user)
if customer:
customer = Customer.objects.get(pk=customer)
if event:
with scopes_disabled():
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
cm = lambda: scope(organizer=event.organizer) # noqa
elif organizer:
with scopes_disabled():
organizer = Organizer.objects.get(id=organizer)
backend = organizer.get_mail_backend()
cm = lambda: scope(organizer=organizer) # noqa
else:
backend = get_connection(fail_silently=False)
cm = lambda: scopes_disabled() # noqa
log_target = order or user or customer
with cm():
if event:
if order:
@@ -432,7 +468,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
logger.exception('Could not attach file to email')
pass
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order)
email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order,
organizer=organizer, customer=customer)
try:
backend.send_messages([email])
@@ -442,9 +479,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
try:
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
except MaxRetriesExceededError:
if order:
order.log_action(
'pretix.event.order.email.error',
if log_target:
log_target.log_action(
'pretix.email.error',
data={
'subject': 'SMTP code {}, max retries exceeded'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
@@ -455,9 +492,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise e
logger.exception('Error sending email')
if order:
order.log_action(
'pretix.event.order.email.error',
if log_target:
log_target.log_action(
'pretix.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
@@ -479,13 +516,13 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
pass
logger.exception('Error sending email')
if order:
if log_target:
message = []
for e, val in e.recipients.items():
message.append(f'{e}: {val[0]} {val[1].decode()}')
order.log_action(
'pretix.event.order.email.error',
logger.log_action(
'pretix.email.error',
data={
'subject': 'SMTP error',
'message': '\n'.join(message),
@@ -500,9 +537,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
try:
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 3)) # max is 2 ** (4*3) = 4096 seconds = 68 minutes
except MaxRetriesExceededError:
if order:
order.log_action(
'pretix.event.order.email.error',
if log_target:
log_target.log_action(
'pretix.email.error',
data={
'subject': 'Internal error',
'message': 'Max retries exceeded',
@@ -511,9 +548,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
}
)
raise e
if order:
order.log_action(
'pretix.event.order.email.error',
if logger:
log_target.log_action(
'pretix.email.error',
data={
'subject': 'Internal error',
'message': str(e),

View File

@@ -0,0 +1,187 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 timedelta
from typing import List, Optional
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import (
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
SubEvent,
)
def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
tz = event.timezone
if item.grant_membership_duration_like_event:
ev = subevent or event
date_start = ev.date_from
date_end = ev.date_to
if not date_end:
# Use end of day, if event end date is not set
date_end = date_start.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999)
else:
# Always start at start of day
date_start = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start
if item.grant_membership_duration_months:
date_end -= timedelta(days=1) # start on 25th gives end on 26th
date_end += relativedelta(months=item.grant_membership_duration_months) # start on 31th may give end on 28th
if item.grant_membership_duration_days:
date_end += timedelta(days=item.grant_membership_duration_days)
if not item.grant_membership_duration_months:
# Correct off-by-one due to first day
date_end -= timedelta(days=1)
# Always end at end of day
date_end = date_end.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999)
return date_start, date_end
def create_membership(customer: Customer, position: OrderPosition):
item = position.item
date_start, date_end = membership_validity(item, position.subevent, position.order.event)
customer.memberships.create(
membership_type=position.item.grant_membership_type,
granted_in=position,
date_start=date_start,
date_end=date_end,
attendee_name_parts=position.attendee_name_parts
)
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None):
"""
Validate that a set of cart or order positions. This currently does not validate
:param customer: Customer to validate for
:param positions: List of order or cart positions
:param event: Event this all is computed in
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
:param ignored_order: An order that should be ignored for usage counting
"""
tz = event.timezone
applicable_positions = [
p for p in positions
if p.item.require_membership or (p.variation and p.variation.require_membership)
]
for p in positions:
if p not in applicable_positions and p.used_membership_id:
raise ValidationError(
_('You selected a membership for the product "{product}" which does not require a membership.').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else '')
)
)
for p in applicable_positions:
if not p.used_membership_id:
raise ValidationError(
_('You selected the product "{product}" which requires an active membership to '
'be selected.').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else '')
)
)
base_qs = Membership.objects.with_usages(ignored_order=ignored_order)
if lock:
base_qs = base_qs.select_for_update()
membership_cache = base_qs\
.select_related('membership_type')\
.prefetch_related('orderposition_set', 'orderposition_set__order', 'orderposition_set__order__event', 'orderposition_set__subevent')\
.in_bulk([p.used_membership_id for p in applicable_positions])
for m in membership_cache.values():
qs = m.orderposition_set.filter(canceled=False).exclude(order__status=Order.STATUS_CANCELED)
if ignored_order:
qs = qs.exclude(order_id=ignored_order.pk)
m._used_at_dates = [
(op.subevent or op.order.event).date_from
for op in qs
]
for p in applicable_positions:
m = membership_cache[p.used_membership_id]
if not customer or m.customer_id != customer.pk:
raise ValidationError(
_('You selected a membership that is connected to a different customer account.')
)
ev = p.subevent or event
if not m.is_valid(ev):
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected an event '
'taking place at {date}.').format(
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
if p.variation and p.variation.require_membership:
types = p.variation.require_membership_types.all()
else:
types = p.item.require_membership_types.all()
if not types.filter(pk=m.membership_type_id).exists():
raise ValidationError(
_('You selected a membership of type "{type}", which is not allowed for the product "{product}".').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else ''),
type=m.membership_type.name
)
)
if m.membership_type.max_usages is not None:
if m.usages >= m.membership_type.max_usages:
raise ValidationError(
_('You are trying to use a membership of type "{type}" more than {number} times, which is the maximum amount.').format(
type=m.membership_type.name,
number=m.usages,
)
)
m.usages += 1
if not m.membership_type.allow_parallel_usage:
df = ev.date_from
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
raise ValidationError(
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
'however you already used the same membership for a different ticket at the same time.').format(
type=m.membership_type.name,
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
m._used_at_dates.append(ev.date_from)

View File

@@ -43,6 +43,7 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
@@ -62,8 +63,8 @@ from pretix.base.i18n import (
LazyLocaleException, get_language_without_region, language,
)
from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order,
OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
Voucher,
)
from pretix.base.models.event import SubEvent
@@ -82,6 +83,9 @@ from pretix.base.services.invoices import (
)
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
from pretix.base.services.pricing import get_price
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -145,7 +149,8 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
raise OrderError('The order was not canceled.')
with order.event.lock() as now_dt:
is_available = force or order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True)
is_available = force or order._is_still_available(now_dt, count_waitinglist=False, check_voucher_usage=True,
check_memberships=True)
if is_available is True:
if order.payment_refund_sum >= order.total:
order.status = Order.STATUS_PAID
@@ -531,7 +536,7 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
sales_channel='web'):
sales_channel='web', customer=None):
err = None
errargs = None
_check_date(event, now_dt)
@@ -553,7 +558,8 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
deleted_positions.add(cp.pk)
cp.delete()
for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))):
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions:
continue
@@ -748,6 +754,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
else:
# Sorry, can't let you keep that!
delete(cp)
if not err:
try:
validate_memberships_in_order(customer, [p for p in sorted_positions if p.pk not in deleted_positions], event, lock=True)
except ValidationError as e:
raise OrderError(e.message)
if err:
raise OrderError(err, errargs)
@@ -786,8 +799,8 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None,
shown_total=None):
meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None,
customer=None):
p = None
sales_channel = get_all_sales_channels()[sales_channel]
@@ -822,8 +835,11 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
testmode=True if sales_channel.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions),
sales_channel=sales_channel.identifier
sales_channel=sales_channel.identifier,
customer=customer,
)
if customer:
order.email_known_to_work = customer.is_verified
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
@@ -927,7 +943,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web',
gift_cards: list=None, shown_total=None):
gift_cards: list=None, shown_total=None, customer=None):
if payment_provider:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
@@ -935,6 +951,9 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
else:
pprov = None
if customer:
customer = event.organizer.customers.get(pk=customer)
if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None
@@ -960,8 +979,8 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
id__in=position_ids, event=event
)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
locale=locale, invoice_address=addr, meta_info=meta_info)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale,
invoice_address=addr, meta_info=meta_info, customer=customer)
lockfn = NoLockManager
locked = False
@@ -980,10 +999,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel)
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
order, payment = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
gift_cards=gift_cards, shown_total=shown_total)
gift_cards=gift_cards, shown_total=shown_total, customer=customer)
free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval
if free_order_flow:
@@ -1228,8 +1247,9 @@ class OrderChangeManager:
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule'))
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership'))
SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee',))
@@ -1287,6 +1307,9 @@ class OrderChangeManager:
self._seatdiff.update([seat])
self._operations.append(self.SeatOperation(position, seat))
def change_membership(self, position: OrderPosition, membership: Membership):
self._operations.append(self.MembershipOperation(position, membership))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
try:
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
@@ -1416,7 +1439,7 @@ class OrderChangeManager:
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None, seat: Seat = None):
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
if isinstance(seat, str):
if not seat:
seat = None
@@ -1468,7 +1491,7 @@ class OrderChangeManager:
self._quotadiff.update(new_quotas)
if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership))
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1633,6 +1656,15 @@ class OrderChangeManager:
event=self.event, position=op.position, force_invalidate=False, save=False
)
op.position.save()
elif isinstance(op, self.MembershipOperation):
self.order.log_action('pretix.event.order.changed.membership', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_membership_id': op.position.used_membership_id,
'new_membership_id': op.membership.pk if op.membership else None,
})
op.position.used_membership = op.membership
op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
'position': op.position.pk,
@@ -1773,7 +1805,8 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat
positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership,
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1783,6 +1816,7 @@ class OrderChangeManager:
'addon_to': op.addon_to.pk if op.addon_to else None,
'price': op.price.gross,
'positionid': pos.positionid,
'membership': pos.used_membership_id,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
})
@@ -1990,6 +2024,50 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist:
return None
def _check_and_lock_memberships(self):
# To avoid duplicating all the logic around memberships, we simulate an application of all relevant
# operations in a non-existing cart and then pass that to our cart checker.
fake_cart = []
positions_to_fake_cart = {}
for p in self.order.positions.all():
cp = CartPosition(
item=p.item,
variation=p.variation,
attendee_name_parts=p.attendee_name_parts,
used_membership=p.used_membership,
subevent=p.subevent,
seat=p.seat,
)
fake_cart.append(cp)
positions_to_fake_cart[p] = cp
for op in self._operations:
if isinstance(op, self.ItemOperation):
positions_to_fake_cart[op.position].item = op.item
positions_to_fake_cart[op.position].variation = op.variation
elif isinstance(op, self.SubeventOperation):
positions_to_fake_cart[op.position].subevent = op.subevent
elif isinstance(op, self.SeatOperation):
positions_to_fake_cart[op.position].seat = op.seat
elif isinstance(op, self.MembershipOperation):
positions_to_fake_cart[op.position].used_membership = op.membership
elif isinstance(op, self.CancelOperation):
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
cp = CartPosition(
item=op.item,
variation=op.variation,
used_membership=op.membership,
subevent=op.subevent,
seat=op.seat,
)
fake_cart.append(cp)
try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order)
except ValidationError as e:
raise OrderError(e.message)
def commit(self, check_quotas=True):
if self._committed:
# an order change can only be committed once
@@ -2011,6 +2089,7 @@ class OrderChangeManager:
self._check_quotas()
self._check_seats()
self._check_complete_cancel()
self._check_and_lock_memberships()
try:
self._perform_operations()
except TaxRule.SaleNotAllowed:
@@ -2055,12 +2134,12 @@ class OrderChangeManager:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: Event, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', gift_cards: list=None, shown_total=None):
sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None):
with language(locale):
try:
try:
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
sales_channel, gift_cards, shown_total)
sales_channel, gift_cards, shown_total, customer)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
@@ -2321,3 +2400,14 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
if any_giftcards:
tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk})
@receiver(order_paid, dispatch_uid="pretixbase_order_paid_memberships")
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_memberships")
@transaction.atomic()
def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
if order.status != Order.STATUS_PAID or not order.customer:
return
for p in order.positions.all():
if p.item.grant_membership_type_id:
create_membership(order.customer, p)