Files
pretix_cgo/src/pretix/base/services/memberships.py
Mira b638c00952 Time machine mode [Z#23129725] (#3961)
Allows organizers to test their shop as if it were a different date and time.

Implemented using a time_machine_now() function which is used instead of regular now(), which can overlay the real date time with a value from a ContextVar, assigned from a session value in EventMiddleware.

For more information, see doc/development/implementation/timemachine.rst

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2024-05-17 10:52:17 +02:00

251 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#
# 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.translation import gettext_lazy as _
from pretix.base.models import (
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
OrderPosition, SubEvent,
)
from pretix.base.timemachine import time_machine_now
from pretix.helpers import OF_SELF
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 = time_machine_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,
testmode=position.order.testmode,
)
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
valid_from_not_chosen=False):
"""
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
:param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed.
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
"""
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(of=OF_SELF)
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 if not op.valid_from or not op.valid_until
]
m._used_for_ranges = [
(op.valid_from, op.valid_until)
for op in qs if op.valid_from or op.valid_until
]
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.')
)
if m.canceled:
raise ValidationError(
_('You selected membership that has been canceled.')
)
if m.testmode and not testmode:
raise ValidationError(
_('You can not use a test mode membership for tickets that are not in test mode.')
)
elif not m.testmode and testmode:
raise ValidationError(
_('You need to add a test mode membership to the customer account to use it in test mode.')
)
ev = p.subevent or event
if isinstance(p, (OrderPosition, CartPosition)):
# override_ variants are for usage of fake cart in OrderChangeManager
valid_from = getattr(p, 'override_valid_from', p.valid_from)
valid_until = getattr(p, 'override_valid_until', p.valid_until)
else: # future safety, not technically defined on AbstractPosition
valid_from = None
valid_until = None
if not m.is_valid(ev, valid_from, valid_from_not_chosen=p.item.validity_dynamic_start_choice and valid_from_not_chosen):
if valid_from:
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected a ticket that '
'starts to be valid on {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(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
else:
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:
if (valid_from or valid_until) and not (p.item.validity_dynamic_start_choice and valid_from_not_chosen):
for used_range in m._used_for_ranges:
if valid_from and valid_from > used_range[1]:
continue
if valid_until and valid_until < used_range[0]:
continue
raise ValidationError(
_('You are trying to use a membership of type "{type}" for a ticket valid from {valid_from} '
'until {valid_until}, however you already used the same membership for a different ticket '
'that overlaps with this time frame ({conflict_from} {conflict_until}).').format(
type=m.membership_type.name,
valid_from=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_from else _('start'),
valid_until=date_format(valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_until else _('open end'),
conflict_from=date_format(used_range[0].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[0] else _('start'),
conflict_until=date_format(used_range[1].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[1] else _('open end'),
)
)
m._used_for_ranges.append((p.valid_from, p.valid_until))
if not valid_from or not valid_until:
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)