mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Customer accounts & Memberships (#2024)
This commit is contained in:
@@ -71,8 +71,9 @@ class BaseHTMLMailRenderer:
|
||||
This is the base class for all HTML e-mail renderers.
|
||||
"""
|
||||
|
||||
def __init__(self, event: Event):
|
||||
def __init__(self, event: Event, organizer=None):
|
||||
self.event = event
|
||||
self.organizer = organizer
|
||||
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
@@ -140,6 +141,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
'color': settings.PRETIX_PRIMARY_COLOR,
|
||||
'rtl': get_language() in settings.LANGUAGES_RTL or get_language().split('-')[0] in settings.LANGUAGES_RTL,
|
||||
}
|
||||
if self.organizer:
|
||||
htmlctx['organizer'] = self.organizer
|
||||
|
||||
if self.event:
|
||||
htmlctx['event'] = self.event
|
||||
htmlctx['color'] = self.event.settings.primary_color
|
||||
|
||||
@@ -234,8 +234,8 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if self.one_required and (not value or not any(v for v in value.values())):
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
if self.one_required:
|
||||
for k, v in value.items():
|
||||
if k in REQUIRED_NAME_PARTS and not v:
|
||||
for k, label, size in self.scheme['fields']:
|
||||
if k in REQUIRED_NAME_PARTS and not value.get(k):
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
|
||||
@@ -85,6 +85,8 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
tzname = None
|
||||
if hasattr(request, 'event'):
|
||||
tzname = request.event.settings.timezone
|
||||
elif hasattr(request, 'organizer') and 'timezone' in request.organizer.settings._cache():
|
||||
tzname = request.organizer.settings.timezone
|
||||
elif request.user.is_authenticated:
|
||||
tzname = request.user.timezone
|
||||
if tzname:
|
||||
@@ -104,6 +106,13 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
return response
|
||||
|
||||
|
||||
def get_language_from_customer_settings(request: HttpRequest) -> str:
|
||||
if getattr(request, 'customer', None):
|
||||
lang_code = request.customer.locale
|
||||
if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
|
||||
return lang_code
|
||||
|
||||
|
||||
def get_language_from_user_settings(request: HttpRequest) -> str:
|
||||
if request.user.is_authenticated:
|
||||
lang_code = request.user.locale
|
||||
@@ -169,6 +178,7 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
if request.path.startswith(get_script_prefix() + 'control'):
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_session_or_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
@@ -177,6 +187,7 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
else:
|
||||
return (
|
||||
get_language_from_session_or_cookie(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_user_settings(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
|
||||
59
src/pretix/base/migrations/0184_customer.py
Normal file
59
src/pretix/base/migrations/0184_customer.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 3.0.13 on 2021-04-06 07:25
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
def set_can_manage_customers(apps, schema_editor):
|
||||
Team = apps.get_model('pretixbase', 'Team')
|
||||
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_customers=True)
|
||||
Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_customers=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0183_auto_20210423_0829'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('identifier', models.CharField(db_index=True, max_length=190, unique=True)),
|
||||
('email', models.EmailField(db_index=True, max_length=190, null=True)),
|
||||
('password', models.CharField(max_length=128)),
|
||||
('name_cached', models.CharField(max_length=255)),
|
||||
('name_parts', jsonfallback.fields.FallbackJSONField(default=dict)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_verified', models.BooleanField(default=True)),
|
||||
('last_login', models.DateTimeField(blank=True, null=True)),
|
||||
('date_joined', models.DateTimeField(auto_now_add=True)),
|
||||
('locale', models.CharField(default='en', max_length=50)),
|
||||
('last_modified', models.DateTimeField(auto_now=True)),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='pretixbase.Organizer')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('organizer', 'email')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='customer',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='pretixbase.Customer'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='can_manage_customers',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_can_manage_customers,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
95
src/pretix/base/migrations/0185_memberships.py
Normal file
95
src/pretix/base/migrations/0185_memberships.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Generated by Django 3.0.13 on 2021-04-08 09:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0184_customer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='grant_membership_duration_days',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='grant_membership_duration_like_event',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='grant_membership_duration_months',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='require_membership',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='require_membership',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MembershipType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', i18nfield.fields.I18nCharField()),
|
||||
('transferable', models.BooleanField(default=False)),
|
||||
('allow_parallel_usage', models.BooleanField(default=False)),
|
||||
('max_usages', models.PositiveIntegerField(null=True)),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='membership_types', to='pretixbase.Organizer')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Membership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('date_start', models.DateTimeField()),
|
||||
('date_end', models.DateTimeField()),
|
||||
('attendee_name_parts', jsonfallback.fields.FallbackJSONField(default=dict, null=True)),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='pretixbase.Customer')),
|
||||
('granted_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='granted_memberships', to='pretixbase.OrderPosition', null=True)),
|
||||
('membership_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='pretixbase.MembershipType')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='used_membership',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Membership'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='grant_membership_type',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='granted_by', to='pretixbase.MembershipType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='require_membership_types',
|
||||
field=models.ManyToManyField(to='pretixbase.MembershipType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='require_membership_types',
|
||||
field=models.ManyToManyField(to='pretixbase.MembershipType'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='used_membership',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Membership'),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User, WebAuthnDevice
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .customers import Customer
|
||||
from .devices import Device, Gate
|
||||
from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
@@ -36,6 +37,7 @@ from .items import (
|
||||
SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .memberships import Membership, MembershipType
|
||||
from .notifications import NotificationSetting
|
||||
from .orders import (
|
||||
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
|
||||
|
||||
173
src/pretix/base/models/customers.py
Normal file
173
src/pretix/base/models/customers.py
Normal file
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# 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 django.conf import settings
|
||||
from django.contrib.auth.hashers import (
|
||||
check_password, is_password_usable, make_password,
|
||||
)
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.organizer import Organizer
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
|
||||
class Customer(LoggedModel):
|
||||
"""
|
||||
Represents a registered customer of an organizer.
|
||||
"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
||||
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_parts = FallbackJSONField(default=dict)
|
||||
is_active = models.BooleanField(default=True, verbose_name=_('Account active'))
|
||||
is_verified = models.BooleanField(default=True, verbose_name=_('Verified email address'))
|
||||
last_login = models.DateTimeField(verbose_name=_('Last login'), blank=True, null=True)
|
||||
date_joined = models.DateTimeField(auto_now_add=True, verbose_name=_('Registration date'))
|
||||
locale = models.CharField(max_length=50,
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_('Language'))
|
||||
last_modified = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = [['organizer', 'email']]
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.email:
|
||||
self.email = self.email.lower()
|
||||
if 'update_fields' in kwargs and 'last_modified' not in kwargs['update_fields']:
|
||||
kwargs['update_fields'] = list(kwargs['update_fields']) + ['last_modified']
|
||||
if not self.identifier:
|
||||
self.assign_identifier()
|
||||
if self.name_parts:
|
||||
self.name_cached = self.name
|
||||
else:
|
||||
self.name_cached = ""
|
||||
self.name_parts = {}
|
||||
super().save(**kwargs)
|
||||
|
||||
def anonymize(self):
|
||||
self.is_active = False
|
||||
self.is_verified = False
|
||||
self.name_parts = {}
|
||||
self.name_cached = ''
|
||||
self.email = None
|
||||
self.save()
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
self.memberships.all().update(attendee_name_parts=None)
|
||||
|
||||
@scopes_disabled()
|
||||
def assign_identifier(self):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
||||
iteration = 0
|
||||
length = settings.ENTROPY['customer_identifier']
|
||||
while True:
|
||||
code = get_random_string(length=length, allowed_chars=charset)
|
||||
iteration += 1
|
||||
|
||||
if banned(code):
|
||||
continue
|
||||
|
||||
if not Customer.objects.filter(identifier=code).exists():
|
||||
self.identifier = code
|
||||
return
|
||||
|
||||
if iteration > 20:
|
||||
# Safeguard: If we don't find an unused and non-banlisted code within 20 iterations, we increase
|
||||
# the length.
|
||||
length += 1
|
||||
iteration = 0
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self.name_parts:
|
||||
return ""
|
||||
if '_legacy' in self.name_parts:
|
||||
return self.name_parts['_legacy']
|
||||
if '_scheme' in self.name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
|
||||
else:
|
||||
raise TypeError("Invalid name given.")
|
||||
return scheme['concatenation'](self.name_parts).strip()
|
||||
|
||||
def __str__(self):
|
||||
s = f'#{self.identifier}'
|
||||
if self.name or self.email:
|
||||
s += f' – {self.name or self.email}'
|
||||
if not self.is_active:
|
||||
s += f' ({_("disabled")})'
|
||||
return s
|
||||
|
||||
def set_password(self, raw_password):
|
||||
self.password = make_password(raw_password)
|
||||
|
||||
def check_password(self, raw_password):
|
||||
"""
|
||||
Return a boolean of whether the raw_password was correct. Handles
|
||||
hashing formats behind the scenes.
|
||||
"""
|
||||
def setter(raw_password):
|
||||
self.set_password(raw_password)
|
||||
self.save(update_fields=["password"])
|
||||
return check_password(raw_password, self.password, setter)
|
||||
|
||||
def set_unusable_password(self):
|
||||
# Set a value that will never be a valid hash
|
||||
self.password = make_password(None)
|
||||
|
||||
def has_usable_password(self):
|
||||
"""
|
||||
Return False if set_unusable_password() has been called for this user.
|
||||
"""
|
||||
return is_password_usable(self.password)
|
||||
|
||||
def get_session_auth_hash(self):
|
||||
"""
|
||||
Return an HMAC of the password field.
|
||||
"""
|
||||
key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash"
|
||||
payload = self.password
|
||||
payload += self.email
|
||||
return salted_hmac(key_salt, payload).hexdigest()
|
||||
|
||||
def get_email_context(self):
|
||||
ctx = {
|
||||
'name': self.name,
|
||||
'organizer': self.organizer.name,
|
||||
}
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
||||
return ctx
|
||||
@@ -670,6 +670,7 @@ class Event(EventMixin, LoggedModel):
|
||||
variation_map = {}
|
||||
for i in Item.objects.filter(event=other).prefetch_related('variations'):
|
||||
vars = list(i.variations.all())
|
||||
require_membership_types = list(i.require_membership_types.all())
|
||||
item_map[i.pk] = i
|
||||
i.pk = None
|
||||
i.event = self
|
||||
@@ -679,8 +680,16 @@ class Event(EventMixin, LoggedModel):
|
||||
i.category = category_map[i.category_id]
|
||||
if i.tax_rule_id:
|
||||
i.tax_rule = tax_map[i.tax_rule_id]
|
||||
|
||||
if i.grant_membership_type and other.organizer_id != self.organizer_id:
|
||||
i.grant_membership_type = None
|
||||
|
||||
i.save()
|
||||
i.log_action('pretix.object.cloned')
|
||||
|
||||
if require_membership_types and other.organizer_id == self.organizer_id:
|
||||
i.require_membership_types.set(require_membership_types)
|
||||
|
||||
for v in vars:
|
||||
variation_map[v.pk] = v
|
||||
v.pk = None
|
||||
|
||||
@@ -514,6 +514,34 @@ class Item(LoggedModel):
|
||||
'product price.'),
|
||||
default=False
|
||||
)
|
||||
require_membership = models.BooleanField(
|
||||
verbose_name=_('Require a valid membership'),
|
||||
default=False,
|
||||
)
|
||||
require_membership_types = models.ManyToManyField(
|
||||
'MembershipType',
|
||||
verbose_name=_('Allowed membership types'),
|
||||
blank=True,
|
||||
)
|
||||
grant_membership_type = models.ForeignKey(
|
||||
'MembershipType',
|
||||
null=True, blank=True,
|
||||
related_name='granted_by',
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_('This product creates a membership of type'),
|
||||
)
|
||||
grant_membership_duration_like_event = models.BooleanField(
|
||||
verbose_name=_('The duration of the membership is the same as the duration of the event or event series date'),
|
||||
default=True,
|
||||
)
|
||||
grant_membership_duration_days = models.IntegerField(
|
||||
verbose_name=_('Membership duration in days'),
|
||||
default=0,
|
||||
)
|
||||
grant_membership_duration_months = models.IntegerField(
|
||||
verbose_name=_('Membership duration in months'),
|
||||
default=0,
|
||||
)
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/forms/item.py if applicable.
|
||||
|
||||
@@ -760,6 +788,15 @@ class ItemVariation(models.Model):
|
||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||
)
|
||||
require_membership = models.BooleanField(
|
||||
verbose_name=_('Require a valid membership'),
|
||||
default=False,
|
||||
)
|
||||
require_membership_types = models.ManyToManyField(
|
||||
'MembershipType',
|
||||
verbose_name=_('Membership types'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='item__event__organizer')
|
||||
|
||||
|
||||
168
src/pretix/base/models/memberships.py
Normal file
168
src/pretix/base/models/memberships.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#
|
||||
# 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 django.db import models
|
||||
from django.db.models import Count, OuterRef, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from i18nfield.fields import I18nCharField
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.models import Customer
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.organizer import Organizer
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
|
||||
class MembershipType(LoggedModel):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
organizer = models.ForeignKey(Organizer, related_name='membership_types', on_delete=models.CASCADE)
|
||||
name = I18nCharField(
|
||||
verbose_name=_('Name'),
|
||||
)
|
||||
transferable = models.BooleanField(
|
||||
verbose_name=_('Membership is transferable'),
|
||||
help_text=_('If this is selected, the membership can be used to purchase tickets for multiple persons. If not, '
|
||||
'the attendee name always needs to stay the same.'),
|
||||
default=False
|
||||
)
|
||||
allow_parallel_usage = models.BooleanField(
|
||||
verbose_name=_('Parallel usage is allowed'),
|
||||
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events.'),
|
||||
default=False
|
||||
)
|
||||
max_usages = models.PositiveIntegerField(
|
||||
verbose_name=_("Maximum usages"),
|
||||
help_text=_("Number of times this membership can be used in a purchase."),
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.memberships.exists() and not self.granted_by.exists()
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
|
||||
@scopes_disabled() # no scoping of subquery
|
||||
def with_usages(self, ignored_order=None):
|
||||
from . import Order, OrderPosition
|
||||
|
||||
sq = OrderPosition.all.filter(
|
||||
used_membership_id=OuterRef('pk'),
|
||||
canceled=False,
|
||||
).exclude(
|
||||
order__status=Order.STATUS_CANCELED
|
||||
)
|
||||
if ignored_order:
|
||||
sq = sq.exclude(order__id=ignored_order.pk)
|
||||
return self.annotate(
|
||||
usages=Coalesce(
|
||||
Subquery(
|
||||
sq.order_by().values('used_membership_id').annotate(
|
||||
c=Count('*')
|
||||
).values('c')
|
||||
),
|
||||
Value('0')
|
||||
)
|
||||
)
|
||||
|
||||
def active(self, ev):
|
||||
return self.filter(
|
||||
date_start__lte=ev.date_from,
|
||||
date_end__gte=ev.date_from
|
||||
)
|
||||
|
||||
|
||||
class MembershipQuerySetManager(ScopedManager(organizer='customer__organizer').__class__):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._queryset_class = MembershipQuerySet
|
||||
|
||||
def with_usages(self, ignored_order=None):
|
||||
return self.get_queryset().with_usages(ignored_order)
|
||||
|
||||
def active(self, ev):
|
||||
return self.get_queryset().active(ev)
|
||||
|
||||
|
||||
class Membership(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
related_name='memberships',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
membership_type = models.ForeignKey(
|
||||
MembershipType,
|
||||
verbose_name=_('Membership type'),
|
||||
related_name='memberships',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
granted_in = models.ForeignKey(
|
||||
'OrderPosition',
|
||||
related_name='granted_memberships',
|
||||
on_delete=models.PROTECT,
|
||||
null=True, blank=True,
|
||||
)
|
||||
date_start = models.DateTimeField(
|
||||
verbose_name=_('Start date')
|
||||
)
|
||||
date_end = models.DateTimeField(
|
||||
verbose_name=_('End date')
|
||||
)
|
||||
attendee_name_parts = FallbackJSONField(default=dict, null=True)
|
||||
|
||||
objects = MembershipQuerySetManager()
|
||||
|
||||
class Meta:
|
||||
ordering = "-date_end", "-date_start", "membership_type"
|
||||
|
||||
def __str__(self):
|
||||
ds = date_format(self.date_start, 'SHORT_DATE_FORMAT')
|
||||
de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
|
||||
return f'{self.membership_type.name}: {self.attendee_name} ({ds} – {de})'
|
||||
|
||||
@property
|
||||
def attendee_name(self):
|
||||
if not self.attendee_name_parts:
|
||||
return None
|
||||
if '_legacy' in self.attendee_name_parts:
|
||||
return self.attendee_name_parts['_legacy']
|
||||
if '_scheme' in self.attendee_name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||
else:
|
||||
scheme = PERSON_NAME_SCHEMES[self.customer.organizer.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
def is_valid(self, ev=None):
|
||||
if ev:
|
||||
dt = ev.date_from
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
return dt >= self.date_start and dt <= self.date_end
|
||||
@@ -47,6 +47,7 @@ import dateutil
|
||||
import pycountry
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models, transaction
|
||||
from django.db.models import (
|
||||
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
||||
@@ -73,7 +74,7 @@ from pretix.base.banlist import banned
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.models import Customer, User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.services.locking import NoLockManager
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
@@ -119,6 +120,8 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
:param event: The event this order belongs to
|
||||
:type event: Event
|
||||
:param customer: The customer this order belongs to
|
||||
:type customer: Customer
|
||||
:param email: The email of the person who ordered this
|
||||
:type email: str
|
||||
:param phone: The phone number of the person who ordered this
|
||||
@@ -177,6 +180,13 @@ class Order(LockModel, LoggedModel):
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
verbose_name=_("Customer"),
|
||||
related_name="orders",
|
||||
null=True, blank=True,
|
||||
on_delete=models.SET_NULL
|
||||
)
|
||||
email = models.EmailField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('E-mail')
|
||||
@@ -822,7 +832,11 @@ class Order(LockModel, LoggedModel):
|
||||
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
|
||||
|
||||
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False,
|
||||
check_voucher_usage=False) -> Union[bool, str]:
|
||||
check_voucher_usage=False, check_memberships=False) -> Union[bool, str]:
|
||||
from pretix.base.services.memberships import (
|
||||
validate_memberships_in_order,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'unavailable': _('The ordered product "{item}" is no longer available.'),
|
||||
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
|
||||
@@ -830,11 +844,17 @@ class Order(LockModel, LoggedModel):
|
||||
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
|
||||
}
|
||||
now_dt = now_dt or now()
|
||||
positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher')
|
||||
positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
|
||||
quota_cache = {}
|
||||
v_budget = {}
|
||||
v_usage = Counter()
|
||||
try:
|
||||
if check_memberships:
|
||||
try:
|
||||
validate_memberships_in_order(self.customer, positions, self.event, lock=False)
|
||||
except ValidationError as e:
|
||||
raise Quota.QuotaExceededException(e.message)
|
||||
|
||||
for i, op in enumerate(positions):
|
||||
if op.seat:
|
||||
if not op.seat.is_available(ignore_orderpos=op):
|
||||
@@ -1181,6 +1201,9 @@ class AbstractPosition(models.Model):
|
||||
voucher = models.ForeignKey(
|
||||
'Voucher', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
used_membership = models.ForeignKey(
|
||||
'Membership', null=True, blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
addon_to = models.ForeignKey(
|
||||
'self', null=True, blank=True, on_delete=models.PROTECT, related_name='addons'
|
||||
)
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
import string
|
||||
from datetime import date, datetime, time
|
||||
|
||||
import pytz
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
@@ -123,6 +125,10 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return pytz.timezone(self.settings.timezone)
|
||||
|
||||
@cached_property
|
||||
def all_logentries_link(self):
|
||||
return reverse(
|
||||
@@ -173,6 +179,24 @@ class Organizer(LoggedModel):
|
||||
e.delete()
|
||||
self.teams.all().delete()
|
||||
|
||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the organizer's settings.
|
||||
"""
|
||||
from pretix.base.email import CustomSMTPBackend
|
||||
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return CustomSMTPBackend(host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
username=self.settings.smtp_username,
|
||||
password=self.settings.smtp_password,
|
||||
use_tls=self.settings.smtp_use_tls,
|
||||
use_ssl=self.settings.smtp_use_ssl,
|
||||
fail_silently=False, timeout=timeout)
|
||||
else:
|
||||
return get_connection(fail_silently=False)
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
@@ -198,6 +222,8 @@ class Team(LoggedModel):
|
||||
:type can_create_events: bool
|
||||
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
|
||||
:type can_change_teams: bool
|
||||
:param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts.
|
||||
:type can_manage_customers: bool
|
||||
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
|
||||
:type can_change_organizer_settings: bool
|
||||
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
|
||||
@@ -235,11 +261,14 @@ class Team(LoggedModel):
|
||||
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
|
||||
'reports, so be careful who you add to this team!')
|
||||
)
|
||||
can_manage_customers = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage customer accounts")
|
||||
)
|
||||
can_manage_gift_cards = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage gift cards")
|
||||
)
|
||||
|
||||
can_change_event_settings = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can change event settings")
|
||||
|
||||
@@ -32,7 +32,7 @@ from cryptography.hazmat.primitives.serialization import (
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Item, ItemVariation, SubEvent
|
||||
from pretix.base.secretgenerators import pretix_sig1_pb2
|
||||
|
||||
@@ -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),
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
@@ -107,6 +107,17 @@ class LazyI18nStringList(UserList):
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
'customer_accounts': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Allow customers to create accounts"),
|
||||
help_text=_("This will allow customers to sign up for an account on your ticket shop. This is a prerequesite for some "
|
||||
"advanced features like memberships.")
|
||||
)
|
||||
},
|
||||
'max_items_per_order': {
|
||||
'default': '10',
|
||||
'type': int,
|
||||
@@ -1753,13 +1764,13 @@ Your {event} team"""))
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {attendee_name},
|
||||
|
||||
you are registered for {event}.
|
||||
you are registered for {event}.
|
||||
|
||||
If you did not do so already, you can download your ticket here:
|
||||
{url}
|
||||
If you did not do so already, you can download your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_download_reminder': {
|
||||
'type': LazyI18nString,
|
||||
@@ -1772,6 +1783,60 @@ If you did not do so already, you can download your ticket here:
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_text_customer_registration': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
|
||||
thank you for signing up for an account at {organizer}!
|
||||
|
||||
To activate your account and set a password, please click here:
|
||||
|
||||
{url}
|
||||
|
||||
This link is valid for one day.
|
||||
|
||||
If you did not sign up yourself, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {organizer} team"""))
|
||||
},
|
||||
'mail_text_customer_email_change': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
|
||||
you requested to change the email address of your account at {organizer}!
|
||||
|
||||
To confirm the change, please click here:
|
||||
|
||||
{url}
|
||||
|
||||
This link is valid for one day.
|
||||
|
||||
If you did not request this, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {organizer} team"""))
|
||||
},
|
||||
'mail_text_customer_reset': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||
|
||||
you requested a new password for your account at {organizer}!
|
||||
|
||||
To set a new password, please click here:
|
||||
|
||||
{url}
|
||||
|
||||
This link is valid for one day.
|
||||
|
||||
If you did not request a new password, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {organizer} team"""))
|
||||
},
|
||||
'smtp_use_custom': {
|
||||
'default': 'False',
|
||||
|
||||
@@ -194,7 +194,7 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
verbose_name = _('E-mails')
|
||||
identifier = 'order_emails'
|
||||
description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email '
|
||||
'contents.')
|
||||
'contents. This will also remove the association to customer accounts.')
|
||||
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'emails-by-order.json', 'application/json', json.dumps({
|
||||
@@ -211,12 +211,13 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
|
||||
for o in self.event.orders.all():
|
||||
o.email = None
|
||||
o.customer = None
|
||||
d = o.meta_info_data
|
||||
if d:
|
||||
if 'contact_form_data' in d and 'email' in d['contact_form_data']:
|
||||
del d['contact_form_data']['email']
|
||||
o.meta_info = json.dumps(d)
|
||||
o.save(update_fields=['meta_info', 'email'])
|
||||
o.save(update_fields=['meta_info', 'email', 'customer'])
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
|
||||
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
|
||||
|
||||
@@ -324,7 +324,7 @@ The ``sender`` keyword argument will contain an organizer.
|
||||
|
||||
validate_order = EventPluginSignal(
|
||||
providing_args=["payment_provider", "positions", "email", "locale", "invoice_address",
|
||||
"meta_info"]
|
||||
"meta_info", "customer"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out when the user tries to confirm the order, before we actually create
|
||||
@@ -633,7 +633,7 @@ well, otherwise it will be ``None``.
|
||||
"""
|
||||
|
||||
global_email_filter = GlobalSignal(
|
||||
providing_args=['message', 'order', 'user']
|
||||
providing_args=['message', 'order', 'user', 'customer', 'organizer']
|
||||
)
|
||||
"""
|
||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||
|
||||
@@ -202,6 +202,9 @@
|
||||
{% if event %}
|
||||
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||
</h2>
|
||||
{% elif organizer %}
|
||||
<h2><a href="{% abseventurl organizer "presale:organizer.index" %}" target="_blank">{{ organizer.name }}</a>
|
||||
</h2>
|
||||
{% else %}
|
||||
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
||||
{% endif %}
|
||||
|
||||
@@ -220,6 +220,9 @@
|
||||
{% if event %}
|
||||
<h2><a href="{% abseventurl event "presale:event.index" %}" target="_blank">{{ event.name }}</a>
|
||||
</h2>
|
||||
{% elif organizer %}
|
||||
<h2><a href="{% abseventurl organizer "presale:organizer.index" %}" target="_blank">{{ organizer.name }}</a>
|
||||
</h2>
|
||||
{% else %}
|
||||
<h2><a href="{{ site_url }}" target="_blank">{{ site }}</a></h2>
|
||||
{% endif %}
|
||||
|
||||
@@ -69,6 +69,8 @@ class EventSlugBanlistValidator(BanlistValidator):
|
||||
'events',
|
||||
'csp_report',
|
||||
'widget',
|
||||
'customer',
|
||||
'account',
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user