mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
Customer accounts & Memberships (#2024)
This commit is contained in:
187
src/pretix/base/services/memberships.py
Normal file
187
src/pretix/base/services/memberships.py
Normal 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)
|
||||
Reference in New Issue
Block a user