Customer accounts & Memberships (#2024)

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

View File

@@ -19,7 +19,8 @@
# 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.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
class FullAccessSecurityProfile:

View File

@@ -160,9 +160,17 @@ class ItemSerializer(I18nAwareModelSerializer):
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data')
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'grant_membership_type',
'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months')
read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):

View File

@@ -44,7 +44,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item,
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
SubEvent, TaxRule, Voucher,
)
@@ -537,7 +537,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
self.fields['subevent'] = SubEventSerializer(read_only=True)
if 'item' in self.context['request'].query_params.getlist('expand'):
self.fields['item'] = ItemSerializer(read_only=True)
self.fields['item'] = ItemSerializer(read_only=True, context=self.context)
if 'variation' in self.context['request'].query_params.getlist('expand'):
self.fields['variation'] = InlineItemVariationSerializer(read_only=True)
@@ -624,6 +624,7 @@ class OrderSerializer(I18nAwareModelSerializer):
payment_date = OrderPaymentDateField(source='*', read_only=True)
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
url = OrderURLField(source='*', read_only=True)
customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True)
class Meta:
model = Order
@@ -631,11 +632,11 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url'
'url', 'customer'
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
)
@@ -907,16 +908,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_email = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
class Meta:
model = Order
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate')
'force', 'send_email', 'simulate', 'customer')
def validate_payment_provider(self, pp):
if pp is None:

View File

@@ -34,8 +34,9 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
@@ -61,6 +62,43 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
fields = ('id', 'name', 'layout')
class CustomerSerializer(I18nAwareModelSerializer):
identifier = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
last_login = serializers.DateTimeField(read_only=True)
date_joined = serializers.DateTimeField(read_only=True)
last_modified = serializers.DateTimeField(read_only=True)
class Meta:
model = Customer
fields = ('identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
'locale', 'last_modified')
class MembershipTypeSerializer(I18nAwareModelSerializer):
class Meta:
model = MembershipType
fields = ('id', 'name', 'transferable', 'allow_parallel_usage', 'max_usages')
class MembershipSerializer(I18nAwareModelSerializer):
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none())
class Meta:
model = Membership
fields = ('id', 'customer', 'membership_type', 'date_start', 'date_end', 'attendee_name_parts')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['customer'].queryset = self.context['organizer'].customers.all()
self.fields['membership_type'].queryset = self.context['organizer'].membership_types.all()
def update(self, instance, validated_data):
validated_data['customer'] = instance.customer # no modifying
return super().update(instance, validated_data)
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00'))
@@ -116,7 +154,7 @@ class TeamSerializer(serializers.ModelSerializer):
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_checkin_orders'
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers'
)
def validate(self, data):
@@ -234,6 +272,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_fields = [
'customer_accounts',
'contact_mail',
'imprint_url',
'organizer_info_text',

View File

@@ -54,6 +54,9 @@ orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'customers', organizer.CustomerViewSet)
orga_router.register(r'memberships', organizer.MembershipViewSet)
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')

View File

@@ -38,14 +38,16 @@ from rest_framework.viewsets import GenericViewSet
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import (
DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer,
OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer,
TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer,
TeamSerializer,
CustomerSerializer, DeviceSerializer, GiftCardSerializer,
GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers.dicts import merge_dicts
@@ -480,3 +482,163 @@ class OrganizerSettingsView(views.APIView):
'request': request
})
return Response(s.data)
with scopes_disabled():
class CustomerFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
class Meta:
model = Customer
fields = ['email']
class CustomerViewSet(viewsets.ModelViewSet):
serializer_class = CustomerSerializer
queryset = Customer.objects.none()
permission = 'can_manage_customers'
lookup_field = 'identifier'
filter_backends = (DjangoFilterBackend,)
filterset_class = CustomerFilter
def get_queryset(self):
qs = self.request.organizer.customers.all()
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_destroy(self, instance):
raise MethodNotAllowed("Customers cannot be deleted.")
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.customer.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.customer.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@action(detail=True, methods=["POST"])
@transaction.atomic()
def anonymize(self, request, **kwargs):
o = self.get_object()
o.anonymize()
o.log_action('pretix.customer.anonymized', user=self.request.user, auth=self.request.auth)
return Response(CustomerSerializer(o).data, status=status.HTTP_200_OK)
class MembershipTypeViewSet(viewsets.ModelViewSet):
serializer_class = MembershipTypeSerializer
queryset = MembershipType.objects.none()
permission = 'can_change_organizer_settings'
def get_queryset(self):
qs = self.request.organizer.membership_types.all()
return qs
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.")
instance.log_action(
'pretix.membershiptype.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.membershiptype.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.membershiptype.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
with scopes_disabled():
class MembershipFilter(FilterSet):
customer = django_filters.CharFilter(field_name='customer__identifier', lookup_expr='iexact')
class Meta:
model = Membership
fields = ['customer', 'membership_type']
class MembershipViewSet(viewsets.ModelViewSet):
serializer_class = MembershipSerializer
queryset = Membership.objects.none()
permission = 'can_manage_customers'
filter_backends = (DjangoFilterBackend,)
filterset_class = MembershipFilter
def get_queryset(self):
return Membership.objects.filter(
customer__organizer=self.request.organizer
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_destroy(self, instance):
raise MethodNotAllowed("Memberships cannot be deleted. You can change the date instead.")
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save()
serializer.instance.customer.log_action(
'pretix.customer.membership.created',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save()
serializer.instance.customer.log_action(
'pretix.customer.membership.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View 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,
),
]

View 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'),
),
]

View File

@@ -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,

View 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

View File

@@ -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

View File

@@ -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')

View 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

View File

@@ -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'
)

View File

@@ -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")

View File

@@ -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

View File

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

View File

@@ -0,0 +1,187 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from typing import List, Optional
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import (
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
SubEvent,
)
def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
tz = event.timezone
if item.grant_membership_duration_like_event:
ev = subevent or event
date_start = ev.date_from
date_end = ev.date_to
if not date_end:
# Use end of day, if event end date is not set
date_end = date_start.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999)
else:
# Always start at start of day
date_start = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start
if item.grant_membership_duration_months:
date_end -= timedelta(days=1) # start on 25th gives end on 26th
date_end += relativedelta(months=item.grant_membership_duration_months) # start on 31th may give end on 28th
if item.grant_membership_duration_days:
date_end += timedelta(days=item.grant_membership_duration_days)
if not item.grant_membership_duration_months:
# Correct off-by-one due to first day
date_end -= timedelta(days=1)
# Always end at end of day
date_end = date_end.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=999999)
return date_start, date_end
def create_membership(customer: Customer, position: OrderPosition):
item = position.item
date_start, date_end = membership_validity(item, position.subevent, position.order.event)
customer.memberships.create(
membership_type=position.item.grant_membership_type,
granted_in=position,
date_start=date_start,
date_end=date_end,
attendee_name_parts=position.attendee_name_parts
)
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None):
"""
Validate that a set of cart or order positions. This currently does not validate
:param customer: Customer to validate for
:param positions: List of order or cart positions
:param event: Event this all is computed in
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
:param ignored_order: An order that should be ignored for usage counting
"""
tz = event.timezone
applicable_positions = [
p for p in positions
if p.item.require_membership or (p.variation and p.variation.require_membership)
]
for p in positions:
if p not in applicable_positions and p.used_membership_id:
raise ValidationError(
_('You selected a membership for the product "{product}" which does not require a membership.').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else '')
)
)
for p in applicable_positions:
if not p.used_membership_id:
raise ValidationError(
_('You selected the product "{product}" which requires an active membership to '
'be selected.').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else '')
)
)
base_qs = Membership.objects.with_usages(ignored_order=ignored_order)
if lock:
base_qs = base_qs.select_for_update()
membership_cache = base_qs\
.select_related('membership_type')\
.prefetch_related('orderposition_set', 'orderposition_set__order', 'orderposition_set__order__event', 'orderposition_set__subevent')\
.in_bulk([p.used_membership_id for p in applicable_positions])
for m in membership_cache.values():
qs = m.orderposition_set.filter(canceled=False).exclude(order__status=Order.STATUS_CANCELED)
if ignored_order:
qs = qs.exclude(order_id=ignored_order.pk)
m._used_at_dates = [
(op.subevent or op.order.event).date_from
for op in qs
]
for p in applicable_positions:
m = membership_cache[p.used_membership_id]
if not customer or m.customer_id != customer.pk:
raise ValidationError(
_('You selected a membership that is connected to a different customer account.')
)
ev = p.subevent or event
if not m.is_valid(ev):
raise ValidationError(
_('You selected a membership that is valid from {start} to {end}, but selected an event '
'taking place at {date}.').format(
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
if p.variation and p.variation.require_membership:
types = p.variation.require_membership_types.all()
else:
types = p.item.require_membership_types.all()
if not types.filter(pk=m.membership_type_id).exists():
raise ValidationError(
_('You selected a membership of type "{type}", which is not allowed for the product "{product}".').format(
product=str(p.item.name) + (' ' + str(p.variation.value) if p.variation else ''),
type=m.membership_type.name
)
)
if m.membership_type.max_usages is not None:
if m.usages >= m.membership_type.max_usages:
raise ValidationError(
_('You are trying to use a membership of type "{type}" more than {number} times, which is the maximum amount.').format(
type=m.membership_type.name,
number=m.usages,
)
)
m.usages += 1
if not m.membership_type.allow_parallel_usage:
df = ev.date_from
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
raise ValidationError(
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
'however you already used the same membership for a different ticket at the same time.').format(
type=m.membership_type.name,
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
)
)
m._used_at_dates.append(ev.date_from)

View File

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

View File

@@ -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',

View File

@@ -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'])

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -69,6 +69,8 @@ class EventSlugBanlistValidator(BanlistValidator):
'events',
'csp_report',
'widget',
'customer',
'account',
]

View File

@@ -45,7 +45,7 @@ from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from ...base.forms import I18nModelForm
from ...base.forms import I18nModelForm, SecretKeySettingsField
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -368,3 +368,46 @@ class SplitDateTimeField(forms.SplitDateTimeField):
class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html'
class SMTPSettingsMixin(forms.Form):
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = SecretKeySettingsField(
label=_("Password"),
required=False,
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
def clean(self):
data = super().clean()
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
return data

View File

@@ -63,7 +63,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
@@ -825,7 +825,7 @@ def contains_web_channel_validate(val):
raise ValidationError(_("The online shop must be selected to receive these emails."))
class MailSettingsForm(SettingsForm):
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
auto_fields = [
'mail_prefix',
'mail_from',
@@ -1020,43 +1020,6 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nTextarea,
)
smtp_use_custom = forms.BooleanField(
label=_("Use custom SMTP server"),
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
required=False
)
smtp_host = forms.CharField(
label=_("Hostname"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
)
smtp_port = forms.IntegerField(
label=_("Port"),
required=False,
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
)
smtp_username = forms.CharField(
label=_("Username"),
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
required=False
)
smtp_password = forms.CharField(
label=_("Password"),
required=False,
widget=forms.PasswordInput(attrs={
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
}),
)
smtp_use_tls = forms.BooleanField(
label=_("Use STARTTLS"),
help_text=_("Commonly enabled on port 587."),
required=False
)
smtp_use_ssl = forms.BooleanField(
label=_("Use SSL"),
help_text=_("Commonly enabled on port 465."),
required=False
)
base_context = {
'mail_text_order_placed': ['event', 'order', 'payment'],
'mail_text_order_placed_attendee': ['event', 'order', 'position'],
@@ -1110,17 +1073,6 @@ class MailSettingsForm(SettingsForm):
# the user interface with it
del self.fields[k]
def clean(self):
data = self.cleaned_data
if not data.get('smtp_password') and data.get('smtp_username'):
# Leave password unchanged if the username is set and the password field is empty.
# This makes it impossible to set an empty password as long as a username is set, but
# Python's smtplib does not support password-less schemes anyway.
data['smtp_password'] = self.initial.get('smtp_password')
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
class TicketSettingsForm(SettingsForm):
auto_fields = [

View File

@@ -1022,6 +1022,44 @@ class GiftCardFilterForm(FilterForm):
return qs.distinct()
class CustomerFilterForm(FilterForm):
orders = {
'email': 'email',
'identifier': 'identifier',
'name_cached': 'name_cached',
}
query = forms.CharField(
label=_('Search query'),
widget=forms.TextInput(attrs={
'placeholder': _('Search query'),
'autofocus': 'autofocus'
}),
required=False
)
def __init__(self, *args, **kwargs):
kwargs.pop('request')
super().__init__(*args, **kwargs)
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(email__icontains=query)
| Q(name_cached__icontains=query)
| Q(identifier__istartswith=query)
)
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by('-email')
return qs
class TeamFilterForm(FilterForm):
orders = {
'name': 'name',

View File

@@ -368,6 +368,11 @@ class ItemCreateForm(I18nModelForm):
'hidden_if_available',
'require_bundling',
'checkin_attention',
'require_membership',
'grant_membership_type',
'grant_membership_duration_like_event',
'grant_membership_duration_days',
'grant_membership_duration_months',
)
for f in fields:
setattr(self.instance, f, getattr(self.cleaned_data['copy_from'], f))
@@ -399,6 +404,10 @@ class ItemCreateForm(I18nModelForm):
'items': [self.instance.pk]
})
if self.cleaned_data.get('copy_from'):
self.instance.require_membership_types.set(
self.cleaned_data['copy_from'].require_membership_types.all()
)
if self.cleaned_data.get('has_variations'):
if self.cleaned_data.get('copy_from') and self.cleaned_data.get('copy_from').has_variations:
for variation in self.cleaned_data['copy_from'].variations.all():
@@ -523,6 +532,19 @@ class ItemUpdateForm(I18nModelForm):
)
self.fields['category'].widget.choices = self.fields['category'].choices
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
self.fields['grant_membership_type'].queryset = qs
self.fields['grant_membership_type'].empty_label = _('No membership granted')
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
del self.fields['grant_membership_type']
del self.fields['grant_membership_duration_like_event']
del self.fields['grant_membership_duration_days']
del self.fields['grant_membership_duration_months']
def clean(self):
d = super().clean()
if d['issue_giftcard']:
@@ -571,15 +593,26 @@ class ItemUpdateForm(I18nModelForm):
'show_quota_left',
'hidden_if_available',
'issue_giftcard',
'require_membership',
'require_membership_types',
'grant_membership_type',
'grant_membership_duration_like_event',
'grant_membership_duration_days',
'grant_membership_duration_months',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'hidden_if_available': SafeModelChoiceField,
'grant_membership_type': SafeModelChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
'generate_tickets': TicketNullBooleanSelect(),
'show_quota_left': ShowQuotaNullBooleanSelect()
}
@@ -632,6 +665,13 @@ class ItemVariationForm(I18nModelForm):
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency)
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
class Meta:
model = ItemVariation
localized_fields = '__all__'
@@ -641,7 +681,14 @@ class ItemVariationForm(I18nModelForm):
'default_price',
'original_price',
'description',
'require_membership',
'require_membership_types'
]
widgets = {
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
}
class ItemAddOnsFormSet(I18nFormSet):

View File

@@ -46,6 +46,7 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import (
gettext_lazy as _, gettext_noop, pgettext_lazy,
)
from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
@@ -290,6 +291,10 @@ class OrderPositionAddForm(forms.Form):
widget=forms.TextInput(attrs={'placeholder': _('General admission'), 'data-seat-guid-field': 'true'}),
label=_('Seat')
)
used_membership = forms.ChoiceField(
label=_('Membership'),
required=False,
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -360,6 +365,23 @@ class OrderPositionAddForm(forms.Form):
del self.fields['subevent']
change_decimal_field(self.fields['price'], order.event.currency)
choices = [
('', ''),
]
if order.customer:
self.memberships = list(order.customer.memberships.all())
for m in self.memberships:
choices.append((str(m.pk), str(m)))
self.fields['used_membership'].choices = choices
def clean(self):
d = super().clean()
if d['used_membership']:
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else:
d['used_membership'] = None
return d
class OrderPositionAddFormset(forms.BaseFormSet):
def __init__(self, *args, **kwargs):
@@ -405,6 +427,9 @@ class OrderPositionChangeForm(forms.Form):
localize=True,
label=_('New price (gross)')
)
used_membership = forms.ChoiceField(
required=False,
)
tax_rule = forms.ModelChoiceField(
TaxRule.objects.none(),
required=False,
@@ -478,6 +503,24 @@ class OrderPositionChangeForm(forms.Form):
self.fields['itemvar'].choices = choices
change_decimal_field(self.fields['price'], instance.order.event.currency)
choices = [
('', _('(Unchanged)')),
('CLEAR', _('(No membership)')),
]
if instance.order.customer:
self.memberships = list(instance.order.customer.memberships.all())
for m in self.memberships:
choices.append((str(m.pk), str(m)))
self.fields['used_membership'].choices = choices
def clean(self):
d = super().clean()
if d['used_membership'] and d['used_membership'] != 'CLEAR':
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
elif not d['used_membership']:
d['used_membership'] = None
return d
class OrderFeeChangeForm(forms.Form):
value = forms.DecimalField(
@@ -516,16 +559,36 @@ class OrderContactForm(forms.ModelForm):
class Meta:
model = Order
fields = ['email', 'email_known_to_work', 'phone']
fields = ['customer', 'email', 'email_known_to_work', 'phone']
widgets = {
'phone': WrappedPhoneNumberPrefixWidget()
'phone': WrappedPhoneNumberPrefixWidget(),
}
field_classes = {
'customer': SafeModelChoiceField,
}
def __init__(self, *args, **kwargs):
customers = kwargs.pop('customers')
super().__init__(*args, **kwargs)
if not self.instance.event.settings.order_phone_asked and not self.instance.phone:
del self.fields['phone']
if customers:
self.fields['customer'].queryset = self.instance.event.organizer.customers.all()
self.fields['customer'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
'organizer': self.instance.event.organizer.slug,
}),
'data-placeholder': _('Customer')
}
)
self.fields['customer'].widget.choices = self.fields['customer'].choices
self.fields['customer'].required = False
else:
del self.fields['customer']
class OrderLocaleForm(forms.ModelForm):
locale = forms.ChoiceField()

View File

@@ -39,20 +39,31 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pytz import common_timezones
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.questions import NamePartsFormField
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
Device, EventMetaProperty, Gate, GiftCard, Organizer, Team,
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
)
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.control.forms import (
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
)
from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate,
)
from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import SafeEventMultipleChoiceField
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
class OrganizerForm(I18nModelForm):
@@ -168,6 +179,12 @@ class EventMetaPropertyForm(forms.ModelForm):
}
class MembershipTypeForm(I18nModelForm):
class Meta:
model = MembershipType
fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages']
class TeamForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
@@ -181,7 +198,7 @@ class TeamForm(forms.ModelForm):
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards',
'can_manage_gift_cards', 'can_manage_customers',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
@@ -250,7 +267,24 @@ class DeviceForm(forms.ModelForm):
class OrganizerSettingsForm(SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Default timezone"),
)
name_scheme = forms.ChoiceField(
label=_("Name format"),
help_text=_("This defines how pretix will ask for human names. Changing this after you already received "
"orders might lead to unexpected behavior when sorting or changing names."),
required=True,
)
name_scheme_titles = forms.ChoiceField(
label=_("Allowed titles"),
help_text=_("If the naming scheme you defined above allows users to input a title, you can use this to "
"restrict the set of selectable titles."),
required=False,
)
auto_fields = [
'customer_accounts',
'contact_mail',
'imprint_url',
'organizer_info_text',
@@ -292,6 +326,115 @@ class OrganizerSettingsForm(SettingsForm):
'We recommend a size of at least 200x200px to accommodate most devices.')
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']),
example=v['concatenation'](v['sample'])
))
for k, v in PERSON_NAME_SCHEMES.items()
)
self.fields['name_scheme_titles'].choices = [('', _('Free text input'))] + [
(k, '{scheme}: {samples}'.format(
scheme=v[0],
samples=', '.join(v[1])
))
for k, v in PERSON_NAME_TITLE_GROUPS.items()
]
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
auto_fields = [
'mail_from',
'mail_from_name',
]
mail_bcc = forms.CharField(
label=_("Bcc address"),
help_text=_("All emails will be sent to this address as a Bcc copy"),
validators=[multimail_validate],
required=False,
max_length=255
)
mail_text_signature = I18nFormField(
label=_("Signature"),
required=False,
widget=I18nTextarea,
help_text=_("This will be attached to every email."),
validators=[PlaceholderValidator([])],
widget_kwargs={'attrs': {
'rows': '4',
'placeholder': _(
'e.g. your contact details'
)
}}
)
mail_text_customer_registration = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_text_customer_email_change = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
mail_text_customer_reset = I18nFormField(
label=_("Text"),
required=False,
widget=I18nTextarea,
)
base_context = {
'mail_text_customer_registration': ['customer', 'url'],
'mail_text_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'],
}
def _get_sample_context(self, base_parameters):
placeholders = {
'organizer': self.organizer.name
}
if 'url' in base_parameters:
placeholders['url'] = build_absolute_uri(
self.organizer,
'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30)
if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
placeholders['name_%s' % f] = name_scheme['sample'][f]
return placeholders
def _set_field_placeholders(self, fn, base_parameters):
phs = [
'{%s}' % p
for p in sorted(self._get_sample_context(base_parameters).keys())
]
ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(phs)
)
def __init__(self, *args, **kwargs):
self.organizer = kwargs.get('obj')
super().__init__(*args, **kwargs)
for k, v in self.base_context.items():
self._set_field_placeholders(k, v)
class WebHookForm(forms.ModelForm):
events = forms.MultipleChoiceField(
@@ -373,3 +516,67 @@ class GiftCardUpdateForm(forms.ModelForm):
'expires': SplitDateTimePickerWidget,
'conditions': forms.Textarea(attrs={"rows": 2})
}
class CustomerUpdateForm(forms.ModelForm):
error_messages = {
'duplicate': _("An account with this email address is already registered."),
}
class Meta:
model = Customer
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=False,
scheme=self.instance.organizer.settings.name_scheme,
titles=self.instance.organizer.settings.name_scheme_titles,
label=_('Name'),
)
def clean(self):
email = self.cleaned_data.get('email')
if email is not None:
try:
self.instance.organizer.customers.exclude(pk=self.instance.pk).get(email=email)
except Customer.DoesNotExist:
pass
else:
raise forms.ValidationError(
self.error_messages['duplicate'],
code='duplicate',
)
return self.cleaned_data
class MembershipUpdateForm(forms.ModelForm):
class Meta:
model = Membership
fields = ['membership_type', 'date_start', 'date_end', 'attendee_name_parts']
field_classes = {
'date_start': SplitDateTimeField,
'date_end': SplitDateTimeField,
}
widgets = {
'date_start': SplitDateTimePickerWidget(),
'date_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_Start'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['membership_type'].queryset = self.instance.customer.organizer.membership_types.all()
self.fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=False,
scheme=self.instance.customer.organizer.settings.name_scheme,
titles=self.instance.customer.organizer.settings.name_scheme_titles,
label=_('Attendee name'),
)

View File

@@ -78,6 +78,10 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.membership':
return text + ' ' + _('Position #{posid}: Used membership changed.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.seat':
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
'to "{new_seat}".').format(
@@ -314,6 +318,17 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.webhook.created': _('The webhook has been created.'),
'pretix.webhook.changed': _('The webhook has been changed.'),
'pretix.membershiptype.created': _('The membership type has been created.'),
'pretix.membershiptype.changed': _('The membership type has been changed.'),
'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
'pretix.customer.created': _('The account has been created.'),
'pretix.customer.changed': _('The account has been changed.'),
'pretix.customer.membership.created': _('A membership for this account has been added.'),
'pretix.customer.membership.changed': _('A membership of this account has been changed.'),
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
'pretix.customer.password.resetrequested': _('A new password has been requested.'),
'pretix.customer.password.set': _('A new password has been set.'),
'pretix.email.error': _('Sending of an email has failed.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
'pretix.event.canceled': _('The event has been canceled.'),
'pretix.event.deleted': _('An event has been deleted.'),
@@ -338,6 +353,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'in the email for the first time).'),
'pretix.event.order.phone.changed': _('The phone number has been changed from "{old_phone}" '
'to "{new_phone}".'),
'pretix.event.order.customer.changed': _('The customer account has been changed.'),
'pretix.event.order.locale.changed': _('The order locale has been changed.'),
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),

View File

@@ -460,6 +460,13 @@ def get_organizer_navigation(request):
}),
'active': url.url_name.startswith('organizer.propert'),
},
{
'label': _('E-mail'),
'url': reverse('control:organizer.settings.mail', kwargs={
'organizer': request.organizer.slug,
}),
'active': url.url_name == 'organizer.settings.mail',
},
{
'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={
@@ -467,9 +474,10 @@ def get_organizer_navigation(request):
}),
'active': 'organizer.webhook' in url.url_name,
'icon': 'bolt',
}
},
]
})
if 'can_change_teams' in request.orgapermset:
nav.append({
'label': _('Teams'),
@@ -490,6 +498,38 @@ def get_organizer_navigation(request):
'icon': 'credit-card',
})
if request.organizer.settings.customer_accounts:
children = []
if 'can_manage_customers' in request.orgapermset:
children.append(
{
'label': _('Customers'),
'url': reverse('control:organizer.customers', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.customer' in url.url_name,
}
)
if 'can_change_organizer_settings' in request.orgapermset:
children.append(
{
'label': _('Membership types'),
'url': reverse('control:organizer.membershiptypes', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.membershiptype' in url.url_name,
}
)
if children:
nav.append({
'label': _('Customer accounts'),
'url': reverse('control:organizer.customers', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'user',
'children': children,
})
if 'can_change_organizer_settings' in request.orgapermset:
nav.append({
'label': _('Devices'),

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% load static %}
{% block inside %}
<h1>{% trans "E-mail settings" %}</h1>
@@ -11,11 +12,14 @@
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %}
{% bootstrap_field form.mail_from layout="control" %}
{% bootstrap_field form.mail_from_name layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
{% endpropagated %}
{% bootstrap_field form.mail_prefix layout="control" %}
{% bootstrap_field form.mail_from layout="control" %}
{% bootstrap_field form.mail_from_name layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
{% bootstrap_field form.mail_attach_tickets layout="control" %}
{% bootstrap_field form.mail_attach_ical layout="control" %}
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
@@ -80,13 +84,15 @@
</fieldset>
<fieldset>
<legend>{% trans "SMTP settings" %}</legend>
{% bootstrap_field form.smtp_use_custom layout="control" %}
{% bootstrap_field form.smtp_host layout="control" %}
{% bootstrap_field form.smtp_port layout="control" %}
{% bootstrap_field form.smtp_username layout="control" %}
{% bootstrap_field form.smtp_password layout="control" %}
{% bootstrap_field form.smtp_use_tls layout="control" %}
{% bootstrap_field form.smtp_use_ssl layout="control" %}
{% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
{% bootstrap_field form.smtp_use_custom layout="control" %}
{% bootstrap_field form.smtp_host layout="control" %}
{% bootstrap_field form.smtp_port layout="control" %}
{% bootstrap_field form.smtp_username layout="control" %}
{% bootstrap_field form.smtp_password layout="control" %}
{% bootstrap_field form.smtp_use_tls layout="control" %}
{% bootstrap_field form.smtp_use_ssl layout="control" %}
{% endpropagated %}
</fieldset>
</div>
<div class="form-group submit-group">

View File

@@ -35,9 +35,15 @@
{% bootstrap_field field show_label=False form_group_class="" %}
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% for l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endfor %}
{% if request.event %}
{% for l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endfor %}
{% else %}
{% for l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endfor %}
{% endif %}
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@
{% load static %}
{% load hierarkey_form %}
{% load formset_tags %}
{% block title %}{% trans "General settings" %}{% endblock %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
@@ -203,7 +204,7 @@
{% bootstrap_field sform.logo_show_title layout="control" %}
{% bootstrap_field sform.og_image layout="control" %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" %}
{% propagated request.event org_url "primary_color" "primary_font" "theme_color_success" "theme_color_danger" "theme_round_borders" %}
{% bootstrap_field sform.primary_color layout="control" %}
{% bootstrap_field sform.theme_color_success layout="control" %}
{% bootstrap_field sform.theme_color_danger layout="control" %}

View File

@@ -46,6 +46,12 @@
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.description layout="control" %}
{% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% bootstrap_field form.require_membership_types layout="control" %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
@@ -80,6 +86,12 @@
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.description layout="control" %}
{% if formset.empty_form.require_membership %}
{% bootstrap_field formset.empty_form.require_membership layout="control" %}
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">
{% bootstrap_field formset.empty_form.require_membership_types layout="control" %}
</div>
{% endif %}
</div>
</div>
{% endescapescript %}

View File

@@ -101,6 +101,12 @@
{% bootstrap_field form.require_voucher layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_bundling layout="control" %}
{% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% bootstrap_field form.require_membership_types layout="control" %}
</div>
{% endif %}
{% bootstrap_field form.allow_cancel layout="control" %}
{% bootstrap_field form.allow_waitinglist layout="control" %}
{% bootstrap_field form.hidden_if_available layout="control" %}
@@ -120,6 +126,28 @@
<legend>{% trans "Additional settings" %}</legend>
{% bootstrap_field form.issue_giftcard layout="control" %}
{% bootstrap_field form.show_quota_left layout="control" %}
{% if form.grant_membership_type %}
{% bootstrap_field form.grant_membership_type layout="control" %}
<div data-display-dependency="#id_grant_membership_type">
{% bootstrap_field form.grant_membership_duration_like_event layout="control" %}
<div data-display-dependency="#id_grant_membership_duration_like_event" data-inverse class="form-group">
{% blocktrans asvar days %}days{% endblocktrans %}
{% blocktrans asvar months %}months{% endblocktrans %}
<label class="col-md-3 col-xs-12 control-label">
{% trans "Membership duration after purchase" %}
</label>
<div class="col-md-4 col-xs-5">
{% bootstrap_field form.grant_membership_duration_months layout="" addon_after=months label_class="sr-only" form_group_class="" %}
</div>
<label class="col-md-1 col-xs-2 control-label text-center">
+
</label>
<div class="col-md-4 col-xs-5">
{% bootstrap_field form.grant_membership_duration_days layout="" addon_after=days label_class="sr-only" form_group_class="" %}
</div>
</div>
</div>
{% endif %}
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% endfor %}

View File

@@ -152,6 +152,18 @@
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Membership" %}</strong>
</div>
<div class="col-sm-5">
{{ position.used_membership|default:"" }}
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field position.form.used_membership layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Price" %}</strong>
@@ -232,6 +244,9 @@
{% if add_form.subevent %}
{% bootstrap_field add_form.subevent layout="control" %}
{% endif %}
{% if add_form.used_membership %}
{% bootstrap_field add_form.used_membership layout="control" %}
{% endif %}
{% bootstrap_field add_form.seat layout="control" %}
</div>
</div>
@@ -264,6 +279,9 @@
{% if add_formset.empty_form.subevent %}
{% bootstrap_field add_formset.empty_form.subevent layout="control" %}
{% endif %}
{% if add_formset.empty_form.used_membership %}
{% bootstrap_field add_formset.empty_form.used_membership layout="control" %}
{% endif %}
{% bootstrap_field add_formset.empty_form.seat layout="control" %}
</div>
</div>

View File

@@ -182,7 +182,20 @@
<dt>{% trans "Expiry date" %}</dt>
<dd>{{ order.expires|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
<dt>{% trans "User" %}</dt>
{% if request.organizer.settings.customer_accounts %}
<dt>{% trans "Customer account" %}</dt>
<dd>
{% if order.customer %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=order.customer.identifier %}">
{{ order.customer.identifier }} {{ order.customer.email }}
</a>
{% endif %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</dd>
{% endif %}
<dt>{% trans "Contact email" %}</dt>
<dd>
{{ order.email|default_if_none:"" }}
{% if order.email and order.email_known_to_work %}
@@ -197,7 +210,7 @@
</a>
{% if order.status != "c" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
@@ -336,7 +349,7 @@
{{ line.seat }}
{% endif %}
{% if line.voucher %}
<br/><span class="fa fa-tags"></span> {% trans "Voucher code used:" %}
<br/><span class="fa fa-tags fa-fw"></span> {% trans "Voucher code used:" %}
<a
{% if line.price_before_voucher|default_if_none:"NONE" != "NONE" %}data-toggle="tooltip" title="{% blocktrans trimmed with price=line.price_before_voucher|money:request.event.currency %}Original price: {{ price }}{% endblocktrans %}"{% endif %}
href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
@@ -345,12 +358,18 @@
{% endif %}
{% if line.subevent %}
<br/>
<span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
<span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% if event.settings.show_times %}
<span class="fa fa-clock-o"></span>
{{ line.subevent.date_from|date:"TIME_FORMAT" }}
{% endif %}
{% endif %}
{% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>
<a href="{% url "control:organizer.customer.membership.edit" organizer=request.organizer.slug customer=order.customer.identifier id=line.used_membership.pk %}">
{{ line.used_membership }}
</a>
{% endif %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}

View File

@@ -113,7 +113,7 @@
<a href="?{% url_replace request 'ordering' '-datetime' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'datetime' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th class="text-right flip">{% trans "Order paid / total" %}
<th class="text-right flip">{% trans "Order paid / total" %}
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
<th class="text-right flip">{% trans "Positions" %}</th>

View File

@@ -0,0 +1,231 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% blocktrans trimmed with id=customer.identifier %}
Customer #{{ id }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with id=customer.identifier %}
Customer #{{ id }}
{% endblocktrans %}
</h1>
<div class="row">
<div class="col-md-10 col-xs-12">
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
{% trans "disabled" %}
{% elif not customer.is_verified %}
{% trans "not yet activated" %}
{% else %}
{% trans "active" %}
{% endif %}
</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>{{ customer.email|default_if_none:"" }}</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
</dl>
<div class="text-right">
<a href="{% url "control:organizer.customer.edit" organizer=request.organizer.slug customer=customer.identifier %}"
class="btn btn-default">
<i class="fa fa-edit"></i> {% trans "Edit" %}
</a>
<a href="{% url "control:organizer.customer.anonymize" organizer=request.organizer.slug customer=customer.identifier %}"
class="btn btn-danger">
<i class="fa fa-trash"></i> {% trans "Anonymize" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Memberships" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Membership type" %}</th>
<th>{% trans "Valid from" %}</th>
<th>{% trans "Valid until" %}</th>
<th>{% trans "Order" %}</th>
<th>{% trans "Attendee name" %}</th>
<th>{% trans "Usages" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<td>
{{ m.membership_type.name }}
</td>
<td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{% if m.granted_in %}
<a href="{% url "control:event.order" event=m.granted_in.order.event.slug organizer=customer.organizer.slug code=m.granted_in.order.code %}">
{{ m.granted_in.order.code }}-{{ m.granted_in.positionid }}
</a>
{% endif %}
</td>
<td>
{{ m.attendee_name }}
</td>
<td>
<div class="quotabox">
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
</div>
</div>
<div class="numbers">
{{ m.usages }} /
{{ m.membership_type.max_usages|default_if_none:"∞" }}
</div>
</div>
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.customer.membership.edit" organizer=request.organizer.slug customer=customer.identifier id=m.pk %}"
data-toggle="tooltip"
title="{% trans "Edit" %}"
class="btn btn-default">
<i class="fa fa-edit"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="7">
<a href="{% url "control:organizer.customer.membership.add" organizer=request.organizer.slug customer=customer.identifier %}"
class="btn btn-default">
<i class="fa fa-plus"></i>
{% trans "Add membership" %}
</a>
</td>
</tr>
</tfoot>
</table>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Orders" %}
</h3>
</div>
<table class="panel-body table">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Order paid / total" %}</th>
<th class="text-right">{% trans "Positions" %}</th>
<th class="text-right">{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a href="{% url "control:event.order" event=o.event.slug organizer=customer.organizer.slug code=o.code %}">
{{ o.code }}
</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.event }}
</td>
<td>
<span class="fa fa-{{ o.sales_channel_obj.icon }} text-muted"
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.customer_id != customer.pk %}
<span class="fa fa-link text-muted"
data-toggle="tooltip"
title="{% trans "Matched to the account based on the email address." %}"
></span>
{% endif %}
</td>
<td class="text-right flip">
{% if o.has_cancellation_request %}
<span class="label label-warning">{% trans "CANCELLATION REQUESTED" %}</span>
{% endif %}
{% if o.has_external_refund or o.has_pending_refund %}
<span class="label label-danger">{% trans "REFUND PENDING" %}</span>
{% elif o.has_pending_refund %}
<span class="label label-warning">{% trans "REFUND PENDING" %}</span>
{% endif %}
{% if o.is_overpaid %}
<span class="label label-warning">{% trans "OVERPAID" %}</span>
{% elif o.is_underpaid %}
<span class="label label-danger">{% trans "UNDERPAID" %}</span>
{% elif o.is_pending_with_full_payment %}
<span class="label label-danger">{% trans "FULLY PAID" %}</span>
{% endif %}
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
<span class="text-muted">
{% endif %}
{{ o.computed_payment_refund_sum|money:o.event.currency }} /
{% if o.computed_payment_refund_sum == o.total or o.computed_payment_refund_sum == 0 %}
</span>
{% endif %}
{{ o.total|money:o.event.currency }}
{% if o.status == "c" and o.icnt %}
<br>
<span class="label label-warning">{% trans "INVOICE NOT CANCELED" %}</span>
{% endif %}
</td>
<td class="text-right flip">{{ o.pcnt|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
</div>
</div>
<div class="col-md-2 col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Customer history" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=customer %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% blocktrans trimmed with id=customer.identifier %}
Anonymize customer #{{ id }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with id=customer.identifier %}
Anonymize customer #{{ id }}
{% endblocktrans %}
</h1>
<p>
{% trans "Are you sure you want to anonymize this customer account?" %}
</p>
<ul>
<li>
{% trans "All orders will be disconnected from this customer account." %}
<strong>
{% trans "The orders themselves will not be anonymized and can still contain personal information!" %}
</strong>
</li>
<li>
{% trans "The customer will no longer be ble to log in and will lose access to any membership benefits." %}
</li>
<li>
{% trans "This action is irreversible." %}
</li>
</ul>
<form action="" method="post">
{% csrf_token %}
<div class="form-group submit-group">
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=customer.identifier %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Anonymize" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% blocktrans trimmed with id=customer.identifier %}
Customer #{{ id }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with id=customer.identifier %}
Customer #{{ id }}
{% endblocktrans %}
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% trans "Membership" %}
{% endblock %}
{% block inner %}
<h1>
{% trans "Membership" %}
</h1>
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group">
<div class="col-md-offset-3 col-md-9">
<button type="submit" class="btn btn-primary btn-lg">
{% trans "Save" %}
</button>
</div>
</div>
</form>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Usages" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Date" context "subevent" %}</th>
<th>{% trans "Product" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Status" %}</th>
</tr>
</thead>
<tbody>
{% for op in usages %}
<tr>
<td>
<strong>
<a href="{% url "control:event.order" event=op.order.event.slug organizer=membership.customer.organizer.slug code=op.order.code %}">
{{ op.order.code }}</a>-{{ op.positionid }}
</strong>
{% if op.order.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ op.order.event }}
</td>
<td>
{{ op.subevent|default:"" }}
</td>
<td>
{{ op.item }}
{% if op.variation %} {{ op.variation }}{% endif %}
</td>
<td>
{{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if op.canceled %}
<span class="label label-danger">
<span class="fa fa-times"></span>
{% trans "Canceled" %}
</span>
{% else %}
{% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load money %}
{% block title %}{% trans "Customers" %}{% endblock %}
{% block inner %}
<h1>
{% trans "Customers" %}
</h1>
{% if customers|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No customer accounts have been created yet.
{% endblocktrans %}
</p>
</div>
{% else %}
<form class="row filter-form" action="" method="get">
<div class="col-md-10 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-2 col-sm-6 col-xs-12">
<button class="btn btn-primary btn-block" type="submit">
<span class="fa fa-filter"></span>
<span class="hidden-md">
{% trans "Filter" %}
</span>
</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Customer ID" %}
<a href="?{% url_replace request 'ordering' '-identifier' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Email" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Name" %}
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a></th>
<th></th>
</tr>
</thead>
<tbody>
{% for c in customers %}
<tr>
<td>
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=c.identifier %}">
{% if not c.is_active %}<strike>{% endif %}
<strong>#{{ c.identifier }}</strong>
{% if not c.is_active %}</strike>{% endif %}
</a>
</td>
<td>
{% if not c.is_verified %}<strike>{% endif %}
{{ c.email|default_if_none:"" }}
{% if not c.is_verified %}</strike>{% endif %}
</td>
<td>{{ c.name }}</td>
<td class="text-right">
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=c.identifier %}"
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
<i class="fa fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}

View File

@@ -50,6 +50,13 @@
<legend>{% trans "Localization" %}</legend>
{% bootstrap_field sform.locales layout="control" %}
{% bootstrap_field sform.region layout="control" %}
{% bootstrap_field sform.timezone layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Customer accounts" %}</legend>
{% bootstrap_field sform.customer_accounts layout="control" %}
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Shop design" %}</legend>

View File

@@ -0,0 +1,58 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
{% endblock %}
{% block title %}{% trans "Organizer" %}{% endblock %}
{% block content %}
<h1>{% trans "E-mail settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data"
mail-preview-url="{% url "control:organizer.settings.mail.preview" organizer=request.organizer.slug %}">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.mail_from layout="control" %}
{% bootstrap_field form.mail_from_name layout="control" %}
{% bootstrap_field form.mail_text_signature layout="control" %}
{% bootstrap_field form.mail_bcc layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "E-mail content" %}</legend>
<div class="panel-group" id="questions_group">
{% blocktrans asvar title_customer_registration %}Customer account registration{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="customer_registration" title=title_customer_registration items="mail_text_customer_registration" %}
{% blocktrans asvar title_email_change %}Customer account email change{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="email_change" title=title_email_change items="mail_text_customer_email_change" %}
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_text_customer_reset" %}
</div>
</fieldset>
<fieldset>
<legend>{% trans "SMTP settings" %}</legend>
{% bootstrap_field form.smtp_use_custom layout="control" %}
{% bootstrap_field form.smtp_host layout="control" %}
{% bootstrap_field form.smtp_port layout="control" %}
{% bootstrap_field form.smtp_username layout="control" %}
{% bootstrap_field form.smtp_password layout="control" %}
{% bootstrap_field form.smtp_use_tls layout="control" %}
{% bootstrap_field form.smtp_use_ssl layout="control" %}
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
{% trans "Save and test custom SMTP connection" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Delete membership type:" %} {{ type.name }}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if is_allowed %}
<p>{% blocktrans %}Are you sure you want to delete this membership type?{% endblocktrans %}
{% else %}
<p>{% blocktrans %}This membership type cannot be deleted since it has already been used.{% endblocktrans %}
{% endif %}
</p>
<div class="form-group submit-group">
<a href="{% url "control:organizer.membershiptypes" organizer=request.organizer.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if is_allowed %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
{% if type %}
<h1>{% trans "Membership type:" %} {{ type.name }}</h1>
{% else %}
<h1>{% trans "Create a new membership type" %}</h1>
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Membership types" %}{% endblock %}
{% block inner %}
<h1>{% trans "Membership types" %}</h1>
<p>
{% blocktrans trimmed %}
You can define membership types. These allow you to link products from different events
together. You can sell a membership as part of a a product in one event, and require valid
memberships to allow purchases in another event.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
This can be used to enable products like year passes, tickets of ten, etc.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.membershiptype.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Create a new membership type" %}
</a>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in types %}
<tr>
<td><strong>
<a href="{% url "control:organizer.membershiptype.edit" organizer=request.organizer.slug type=t.id %}">
{{ t.name }}
</a>
</strong></td>
<td class="text-right flip">
<a href="{% url "control:organizer.membershiptype.edit" organizer=request.organizer.slug type=t.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.membershiptype.delete" organizer=request.organizer.slug type=t.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Delete property:" %} {{ gate.name }}</h1>
<h1>{% trans "Delete property:" %} {{ type.name }}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the property?{% endblocktrans %}

View File

@@ -2,7 +2,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
{% if gate %}
{% if property %}
<h1>{% trans "Property:" %} {{ property.name }}</h1>
{% else %}
<h1>{% trans "Create a new property" %}</h1>

View File

@@ -23,6 +23,7 @@
<legend>{% trans "Organizer permissions" %}</legend>
{% bootstrap_field form.can_create_events layout="control" %}
{% bootstrap_field form.can_manage_gift_cards layout="control" %}
{% bootstrap_field form.can_manage_customers layout="control" %}
{% bootstrap_field form.can_change_teams layout="control" %}
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
</fieldset>

View File

@@ -42,39 +42,41 @@ class PropagatedNode(Node):
if all([fn not in event.settings._cache() for fn in self.field_names]):
body = """
<div class="propagated-settings-box">
<input type="hidden" name="_settings_ignore" value="{fnames}">
<div class="propagated-settings-form blurred">
{body}
</div>
<div class="propagated-settings-overlay">
<h4><span class="fa fa-link"></span> {text_inh}</h4>
<p>
{text_expl}
</p>
<button class="btn btn-default" name="decouple" value="{fnames}" data-action="unlink">
<span class="fa fa-unlink"></span> {text_unlink}
<div class="propagated-settings-box locked panel panel-default">
<div class="panel-heading">
<input type="hidden" name="_settings_ignore" value="{fnames}">
<button class="btn btn-default pull-right btn-xs" name="decouple" value="{fnames}" data-action="unlink">
<span class="fa fa-unlock"></span> {text_unlink}
</button>
<a class="btn btn-default" href="{url}" target="_blank">
<span class="fa fa-group"></span> {text_orga}
<h4 class="panel-title">
<span class="fa fa-lock"></span> {text_inh}
</h4>
</div>
<div class="panel-body help-text">
{text_expl}<br>
<a href="{url}" target="_blank">
{text_orga}
</a>
</div>
<div class="panel-body propagated-settings-form">
{body}
</div>
</div>
""".format(
body=body,
text_inh=_("Organizer-level settings") if isinstance(event, Event) else _('Site-level settings'),
text_inh=_("Currently set on organizer level") if isinstance(event, Event) else _('Currently set on global level'),
fnames=','.join(self.field_names),
text_expl=_(
'These settings are currently set on organizer level. This way, you can easily change them for '
'all of your events at the same time. You can either go to the organizer settings to change them '
'or decouple them from the organizer account to change them for this event individually.'
'all of your events at the same time. You can either go to the organizer settings to change them for all your events '
'or you can unlock them to change them for this event individually.'
) if isinstance(event, Event) else _(
'These settings are currently set on global level. This way, you can easily change them for '
'all organizers at the same time. You can either go to the global settings to change them '
'or decouple them from the global settings to change them for this event individually.'
'all organizers at the same time. You can either go to the global settings to change them for all your organizers '
'or you can unlock them to change them for this event individually.'
),
text_unlink=_('Change only for this event') if isinstance(event, Event) else _('Change only for this organizer'),
text_orga=_('Change for all events') if isinstance(event, Event) else _('Change for all organizers'),
text_unlink=_('Unlock'),
text_orga=_('Go to organizer settings') if isinstance(event, Event) else _('Go to global settings'),
url=url
)

View File

@@ -110,6 +110,10 @@ urlpatterns = [
url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
url(r'^organizer/(?P<organizer>[^/]+)/settings/email/preview$',
organizer.MailSettingsPreview.as_view(), name='organizer.settings.mail.preview'),
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
name='organizer.display'),
@@ -120,6 +124,25 @@ urlpatterns = [
name='organizer.property.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/property/(?P<property>[^/]+)/delete$', organizer.EventMetaPropertyDeleteView.as_view(),
name='organizer.property.delete'),
url(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'),
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(),
name='organizer.membershiptype.add'),
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/(?P<type>[^/]+)/edit$', organizer.MembershipTypeUpdateView.as_view(),
name='organizer.membershiptype.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/membershiptype/(?P<type>[^/]+)/delete$', organizer.MembershipTypeDeleteView.as_view(),
name='organizer.membershiptype.delete'),
url(r'^organizer/(?P<organizer>[^/]+)/customers$', organizer.CustomerListView.as_view(), name='organizer.customers'),
url(r'^organizer/(?P<organizer>[^/]+)/customers/select2$', typeahead.customer_select2, name='organizer.customers.select2'),
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/$',
organizer.CustomerDetailView.as_view(), name='organizer.customer'),
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/edit$',
organizer.CustomerUpdateView.as_view(), name='organizer.customer.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/add$',
organizer.MembershipCreateView.as_view(), name='organizer.customer.membership.add'),
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/membership/(?P<id>[^/]+)/edit$',
organizer.MembershipUpdateView.as_view(), name='organizer.customer.membership.edit'),
url(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
url(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
url(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),

View File

@@ -277,7 +277,7 @@ class Forgot(TemplateView):
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
except User.DoesNotExist:
logger.warning('Password reset for unregistered e-mail \"' + email + '\"requested.')
logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException:
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')

View File

@@ -336,7 +336,7 @@ class OrderDetail(OrderView):
cartpos = queryset.order_by(
'item', 'variation'
).select_related(
'item', 'variation', 'addon_to', 'tax_rule'
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type'
).prefetch_related(
'item__questions', 'issued_gift_cards',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
@@ -1568,7 +1568,10 @@ class OrderChange(OrderView):
@cached_property
def positions(self):
positions = list(self.order.positions.select_related('item', 'item__tax_rule'))
positions = list(self.order.positions.select_related(
'item', 'item__tax_rule', 'used_membership', 'used_membership__membership_type', 'tax_rule',
'seat', 'subevent',
))
for p in positions:
p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items,
initial={'seat': p.seat.seat_guid if p.seat else None},
@@ -1616,7 +1619,8 @@ class OrderChange(OrderView):
f.cleaned_data['price'],
f.cleaned_data.get('addon_to'),
f.cleaned_data.get('subevent'),
f.cleaned_data.get('seat'))
f.cleaned_data.get('seat'),
f.cleaned_data.get('used_membership'))
except OrderError as e:
f.custom_error = str(e)
return False
@@ -1685,6 +1689,12 @@ class OrderChange(OrderView):
if p.form.cleaned_data['price'] is not None and p.form.cleaned_data['price'] != p.price:
ocm.change_price(p, p.form.cleaned_data['price'])
if p.form.cleaned_data['used_membership'] is not None and p.form.cleaned_data['used_membership'] != (p.used_membership or 'CLEAR'):
if p.form.cleaned_data['used_membership'] == 'CLEAR':
ocm.change_membership(p, None)
else:
ocm.change_membership(p, p.form.cleaned_data['used_membership'])
if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule:
ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule'])
@@ -1792,12 +1802,18 @@ class OrderContactChange(OrderView):
def form(self):
return OrderContactForm(
instance=self.order,
data=self.request.POST if self.request.method == "POST" else None
data=self.request.POST if self.request.method == "POST" else None,
customers=self.request.organizer.settings.customer_accounts and (
self.request.user.has_organizer_permission(
self.request.organizer, 'can_manage_customers', request=self.request
)
)
)
def post(self, *args, **kwargs):
old_email = self.order.email
old_phone = self.order.phone
old_customer = self.order.customer
changed = False
if self.form.is_valid():
new_email = self.form.cleaned_data['email']
@@ -1824,6 +1840,18 @@ class OrderContactChange(OrderView):
user=self.request.user,
)
new_customer = self.form.cleaned_data.get('customer')
if new_customer != old_customer:
changed = True
self.order.log_action(
'pretix.event.order.customer.changed',
data={
'old_customer': old_customer,
'new_customer': self.form.cleaned_data['customer'],
},
user=self.request.user,
)
if self.form.cleaned_data['regenerate_secrets']:
changed = True
self.order.secret = generate_secret()

View File

@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import json
import re
from datetime import timedelta
from decimal import Decimal
@@ -43,11 +44,12 @@ from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.db import transaction
from django.db.models import (
Count, Max, Min, OuterRef, Prefetch, ProtectedError, Subquery, Sum,
Count, Exists, IntegerField, Max, Min, OuterRef, Prefetch, ProtectedError,
Q, Subquery, Sum,
)
from django.db.models.functions import Coalesce, Greatest
from django.forms import DecimalField
from django.http import JsonResponse
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
@@ -61,29 +63,37 @@ from django.views.generic import (
from pretix.api.models import WebHook
from pretix.base.auth import get_auth_backends
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Device, Gate, GiftCard, LogEntry, OrderPayment, Organizer,
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
Team, TeamInvite, User,
)
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
from pretix.base.models.giftcards import (
GiftCardTransaction, gen_giftcard_secret,
)
from pretix.base.models.orders import CancellationRequest
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.payment import PaymentException
from pretix.base.services.export import multiexport
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.base.signals import register_multievent_data_exporters
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.filter import (
EventFilterForm, GiftCardFilterForm, OrganizerFilterForm, TeamFilterForm,
CustomerFilterForm, EventFilterForm, GiftCardFilterForm,
OrganizerFilterForm, TeamFilterForm,
)
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
DeviceForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm,
GiftCardUpdateForm, OrganizerDeleteForm, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm,
CustomerUpdateForm, DeviceForm, EventMetaPropertyForm, GateForm,
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
WebHookForm,
)
from pretix.control.logdisplay import OVERVIEW_BANLIST
from pretix.control.permissions import (
@@ -228,6 +238,104 @@ class OrganizerSettingsFormView(OrganizerDetailViewMixin, OrganizerPermissionReq
return self.get(request)
class OrganizerMailSettings(OrganizerSettingsFormView):
form_class = MailSettingsForm
template_name = 'pretixcontrol/organizers/mail.html'
permission = 'can_change_organizer_settings'
def get_success_url(self):
return reverse('control:organizer.settings.mail', kwargs={
'organizer': self.request.organizer.slug,
})
@transaction.atomic
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
form.save()
if form.has_changed():
self.request.organizer.log_action(
'pretix.organizer.settings', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
if request.POST.get('test', '0').strip() == '1':
backend = self.request.organizer.get_mail_backend(force_custom=True, timeout=10)
try:
backend.test(self.request.organizer.settings.mail_from)
except Exception as e:
messages.warning(self.request, _('An error occurred while contacting the SMTP server: %s') % str(e))
else:
if form.cleaned_data.get('smtp_use_custom'):
messages.success(self.request, _('Your changes have been saved and the connection attempt to '
'your SMTP server was successful.'))
else:
messages.success(self.request, _('We\'ve been able to contact the SMTP server you configured. '
'Remember to check the "use custom SMTP server" checkbox, '
'otherwise your SMTP server will not be used.'))
else:
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.get(request)
class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
permission = 'can_change_organizer_settings'
# return the origin text if key is missing in dict
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
# create index-language mapping
@cached_property
def supported_locale(self):
locales = {}
for idx, val in enumerate(settings.LANGUAGES):
if val[0] in self.request.organizer.settings.locales:
locales[str(idx)] = val[0]
return locales
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
for p, s in MailSettingsForm(obj=self.request.organizer)._get_sample_context(MailSettingsForm.base_context[item]).items():
if s.strip().startswith('*'):
ctx[p] = s
else:
ctx[p] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
s
)
return self.SafeDict(ctx)
def post(self, request, *args, **kwargs):
preview_item = request.POST.get('item', '')
if preview_item not in MailSettingsForm.base_context:
return HttpResponseBadRequest(_('invalid item'))
regex = r"^" + re.escape(preview_item) + r"_(?P<idx>[\d+])$"
msgs = {}
for k, v in request.POST.items():
# only accept allowed fields
matched = re.search(regex, k)
if matched is not None:
idx = matched.group('idx')
if idx in self.supported_locale:
with language(self.supported_locale[idx], self.request.organizer.settings.region):
msgs[self.supported_locale[idx]] = markdown_compile_email(
v.format_map(self.placeholders(preview_item))
)
return JsonResponse({
'item': preview_item,
'msgs': msgs
})
class OrganizerDisplaySettings(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, View):
permission = None
@@ -1502,3 +1610,339 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
return ctx
class MembershipTypeListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = MembershipType
template_name = 'pretixcontrol/organizers/membershiptypes.html'
permission = 'can_change_organizer_settings'
context_object_name = 'types'
def get_queryset(self):
return self.request.organizer.membership_types.all()
class MembershipTypeCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
model = MembershipType
template_name = 'pretixcontrol/organizers/membershiptype_edit.html'
permission = 'can_change_organizer_settings'
form_class = MembershipTypeForm
def get_object(self, queryset=None):
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
def get_success_url(self):
return reverse('control:organizer.membershiptypes', kwargs={
'organizer': self.request.organizer.slug,
})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.organizer
return kwargs
def form_valid(self, form):
messages.success(self.request, _('The membership type has been created.'))
form.instance.organizer = self.request.organizer
ret = super().form_valid(form)
form.instance.log_action('pretix.membershiptype.created', user=self.request.user, data={
k: getattr(self.object, k) for k in form.changed_data
})
return ret
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class MembershipTypeUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
model = MembershipType
template_name = 'pretixcontrol/organizers/membershiptype_edit.html'
permission = 'can_change_organizer_settings'
context_object_name = 'type'
form_class = MembershipTypeForm
def get_object(self, queryset=None):
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
def get_success_url(self):
return reverse('control:organizer.membershiptypes', kwargs={
'organizer': self.request.organizer.slug,
})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.organizer
return kwargs
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.membershiptype.changed', user=self.request.user, data={
k: getattr(self.object, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class MembershipTypeDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DeleteView):
model = MembershipType
template_name = 'pretixcontrol/organizers/membershiptype_delete.html'
permission = 'can_change_organizer_settings'
context_object_name = 'type'
def get_object(self, queryset=None):
return get_object_or_404(MembershipType, organizer=self.request.organizer, pk=self.kwargs.get('type'))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['is_allowed'] = self.object.allow_delete()
return ctx
def get_success_url(self):
return reverse('control:organizer.membershiptypes', kwargs={
'organizer': self.request.organizer.slug,
})
@transaction.atomic
def delete(self, request, *args, **kwargs):
success_url = self.get_success_url()
self.object = self.get_object()
if self.object.allow_delete():
self.object.log_action('pretix.membershiptype.deleted', user=self.request.user)
self.object.delete()
messages.success(request, _('The selected object has been deleted.'))
return redirect(success_url)
class CustomerListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
model = Customer
template_name = 'pretixcontrol/organizers/customers.html'
permission = 'can_manage_customers'
context_object_name = 'customers'
def get_queryset(self):
qs = self.request.organizer.customers.all()
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return CustomerFilterForm(data=self.request.GET, request=self.request)
class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
template_name = 'pretixcontrol/organizers/customer.html'
permission = 'can_manage_customers'
context_object_name = 'orders'
def get_queryset(self):
qs = Order.objects.filter(
Q(customer=self.customer)
| Q(email__iexact=self.customer.email)
).select_related('event').order_by('-datetime')
return qs
@cached_property
def customer(self):
return get_object_or_404(
self.request.organizer.customers,
identifier=self.kwargs.get('customer')
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['customer'] = self.customer
ctx['display_locale'] = dict(settings.LANGUAGES)[self.customer.locale or self.request.organizer.settings.locale]
ctx['memberships'] = self.customer.memberships.with_usages().select_related(
'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event'
)
for m in ctx['memberships']:
if m.membership_type.max_usages:
m.percent = int(m.usages / m.membership_type.max_usages * 100)
else:
m.percent = 0
# Only compute this annotations for this page (query optimization)
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
i = Invoice.objects.filter(
order=OuterRef('pk'),
is_cancellation=False,
refered__isnull=True,
).order_by().values('order').annotate(k=Count('id')).values('k')
annotated = {
o['pk']: o
for o in
Order.annotate_overpayments(Order.objects, sums=True).filter(
pk__in=[o.pk for o in ctx['orders']]
).annotate(
pcnt=Subquery(s, output_field=IntegerField()),
icnt=Subquery(i, output_field=IntegerField()),
has_cancellation_request=Exists(CancellationRequest.objects.filter(order=OuterRef('pk')))
).values(
'pk', 'pcnt', 'is_overpaid', 'is_underpaid', 'is_pending_with_full_payment', 'has_external_refund',
'has_pending_refund', 'has_cancellation_request', 'computed_payment_refund_sum', 'icnt'
)
}
scs = get_all_sales_channels()
for o in ctx['orders']:
if o.pk not in annotated:
continue
o.pcnt = annotated.get(o.pk)['pcnt']
o.is_overpaid = annotated.get(o.pk)['is_overpaid']
o.is_underpaid = annotated.get(o.pk)['is_underpaid']
o.is_pending_with_full_payment = annotated.get(o.pk)['is_pending_with_full_payment']
o.has_external_refund = annotated.get(o.pk)['has_external_refund']
o.has_pending_refund = annotated.get(o.pk)['has_pending_refund']
o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request']
o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum']
o.icnt = annotated.get(o.pk)['icnt']
o.sales_channel_obj = scs[o.sales_channel]
return ctx
class CustomerUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
template_name = 'pretixcontrol/organizers/customer_edit.html'
permission = 'can_manage_customers'
context_object_name = 'customer'
form_class = CustomerUpdateForm
def get_object(self, queryset=None):
return get_object_or_404(
self.request.organizer.customers,
identifier=self.kwargs.get('customer')
)
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.customer.changed', user=self.request.user, data={
k: getattr(self.object, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_success_url(self):
return reverse('control:organizer.customer', kwargs={
'organizer': self.request.organizer.slug,
'customer': self.object.identifier,
})
class MembershipUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
template_name = 'pretixcontrol/organizers/customer_membership.html'
permission = 'can_manage_customers'
context_object_name = 'membership'
form_class = MembershipUpdateForm
def get_object(self, queryset=None):
return get_object_or_404(
Membership,
customer__organizer=self.request.organizer,
customer__identifier=self.kwargs.get('customer'),
pk=self.kwargs.get('id')
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['usages'] = self.object.orderposition_set.select_related(
'order', 'order__event', 'subevent', 'item', 'variation',
)
return ctx
def form_valid(self, form):
if form.has_changed():
d = {
k: getattr(self.object, k)
for k in form.changed_data
}
d['id'] = self.object.pk
self.object.customer.log_action('pretix.customer.membership.changed', user=self.request.user, data=d)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_success_url(self):
return reverse('control:organizer.customer', kwargs={
'organizer': self.request.organizer.slug,
'customer': self.object.customer.identifier,
})
class MembershipCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
template_name = 'pretixcontrol/organizers/customer_membership.html'
permission = 'can_manage_customers'
context_object_name = 'membership'
form_class = MembershipUpdateForm
@cached_property
def customer(self):
return get_object_or_404(
self.request.organizer.customers,
identifier=self.kwargs.get('customer')
)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['instance'] = Membership(
customer=self.customer,
)
return kwargs
def form_valid(self, form):
r = super().form_valid(form)
d = {
k: getattr(self.object, k)
for k in form.changed_data
}
d['id'] = self.object.pk
self.customer.log_action('pretix.customer.membership.created', user=self.request.user, data=d)
messages.success(self.request, _('Your changes have been saved.'))
return r
def get_success_url(self):
return reverse('control:organizer.customer', kwargs={
'organizer': self.request.organizer.slug,
'customer': self.object.customer.identifier,
})
class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
template_name = 'pretixcontrol/organizers/customer_anonymize.html'
permission = 'can_manage_customers'
context_object_name = 'customer'
def get_object(self, queryset=None):
return get_object_or_404(
self.request.organizer.customers,
identifier=self.kwargs.get('customer')
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
with transaction.atomic():
self.object.anonymize()
self.object.log_action('pretix.customer.anonymized', user=self.request.user)
messages.success(self.request, _('The customer account has been anonymized.'))
return redirect(self.get_success_url())
def get_success_url(self):
return reverse('control:organizer.customer', kwargs={
'organizer': self.request.organizer.slug,
'customer': self.object.identifier,
})

View File

@@ -51,7 +51,9 @@ from pretix.base.models import (
ItemVariation, Order, Organizer, User, Voucher,
)
from pretix.control.forms.event import EventWizardCopyForm
from pretix.control.permissions import event_permission_required
from pretix.control.permissions import (
event_permission_required, organizer_permission_required,
)
from pretix.helpers.daterange import daterange
from pretix.helpers.i18n import i18ncomp
@@ -169,6 +171,36 @@ def event_list(request):
return JsonResponse(doc)
@organizer_permission_required("can_manage_customers")
def customer_select2(request, **kwargs):
query = request.GET.get('query', '')
try:
page = int(request.GET.get('page', '1'))
except ValueError:
page = 1
qs = request.organizer.customers.filter(
Q(email__icontains=query) | Q(name_cached__icontains=query) | Q(identifier__istartswith=query)
).order_by('name_cached')
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
doc = {
'results': [
{
'id': e.pk,
'text': str(e),
}
for e in qs[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}
}
return JsonResponse(doc)
def nav_context_list(request):
query = request.GET.get('query', '').strip()
organizer = request.GET.get('organizer', None)

View File

@@ -40,6 +40,7 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.db.models import F, Q
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect
from django.utils import translation
@@ -50,26 +51,29 @@ from django.utils.translation import (
from django.views.generic.base import TemplateResponseMixin
from django_scopes import scopes_disabled
from pretix.base.models import Order
from pretix.base.models import Customer, Order
from pretix.base.models.orders import InvoiceAddress, OrderPayment
from pretix.base.models.tax import TaxedPrice, TaxRule
from pretix.base.services.cart import (
CartError, error_messages, get_fees, set_cart_addons, update_tax_rates,
)
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.rich_text import rich_text_snippet
from pretix.base.views.tasks import AsyncAction
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.forms.checkout import (
ContactForm, InvoiceAddressForm, InvoiceNameForm,
ContactForm, InvoiceAddressForm, InvoiceNameForm, MembershipForm,
)
from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm
from pretix.presale.signals import (
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
contact_form_fields, contact_form_fields_overrides,
order_meta_from_request, question_form_fields,
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
from pretix.presale.views import (
CartMixin, get_cart, get_cart_is_free, get_cart_total,
)
@@ -222,6 +226,200 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
class CustomerStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 45
identifier = "customer"
template_name = "pretixpresale/event/checkout_customer.html"
label = pgettext_lazy('checkoutflow', 'Customer account')
icon = 'user'
def is_applicable(self, request):
return request.organizer.settings.customer_accounts
@cached_property
def login_form(self):
f = AuthenticationForm(
data=(
self.request.POST
if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'login'
else None
),
prefix='login',
request=self.request.event,
)
for field in f.fields.values():
field._show_required = field.required
field.required = False
field.widget.is_required = False
return f
@cached_property
def guest_allowed(self):
return not any(
p.item.require_membership or
(p.variation and p.variation.require_membership) or
p.item.grant_membership_type_id
for p in self.positions
)
@cached_property
def register_form(self):
f = RegistrationForm(
data=(
self.request.POST
if self.request.method == "POST" and self.request.POST.get('customer_mode') == 'register'
else None
),
prefix='register',
request=self.request,
)
for field in f.fields.values():
field._show_required = field.required
field.required = False
field.widget.is_required = False
return f
def post(self, request):
self.request = request
if request.POST.get("customer_mode") == 'login':
if 'customer' in self.cart_session:
return redirect(self.get_next_url(request))
elif request.customer:
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = request.customer.pk
return redirect(self.get_next_url(request))
elif self.login_form.is_valid():
customer_login(self.request, self.login_form.get_customer())
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = self.login_form.get_customer().pk
return redirect(self.get_next_url(request))
else:
return self.render()
elif request.POST.get("customer_mode") == 'register':
if self.register_form.is_valid():
customer = self.register_form.create()
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = customer.pk
return redirect(self.get_next_url(request))
else:
return self.render()
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
self.cart_session['customer'] = None
self.cart_session['customer_mode'] = 'guest'
return redirect(self.get_next_url(request))
else:
return self.render()
def is_completed(self, request, warn=False):
self.request = request
if self.guest_allowed:
return 'customer_mode' in self.cart_session
else:
return self.cart_session.get('customer_mode') == 'login'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart()
ctx['cart_session'] = self.cart_session
ctx['login_form'] = self.login_form
ctx['register_form'] = self.register_form
ctx['selected'] = self.request.POST.get(
'customer_mode',
self.cart_session.get('customer_mode', 'login' if self.request.customer else '')
)
ctx['guest_allowed'] = self.guest_allowed
if 'customer' in self.cart_session:
try:
ctx['customer'] = self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
except Customer.DoesNotExist:
self.cart_session['customer'] = None
self.cart_session['customer_mode'] = None
elif self.request.customer:
ctx['customer'] = self.request.customer
return ctx
class MembershipStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 47
identifier = "membership"
template_name = "pretixpresale/event/checkout_membership.html"
label = pgettext_lazy('checkoutflow', 'Membership')
icon = 'id-card'
def is_applicable(self, request):
self.request = request
return bool(self.applicable_positions)
@cached_property
def applicable_positions(self):
return [
p for p in self.positions
if p.item.require_membership or (p.variation and p.variation.require_membership)
]
@cached_property
def forms(self):
forms = []
memberships = list(self.cart_customer.memberships.with_usages().filter(
Q(Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages'))),
).select_related('membership_type'))
for p in self.applicable_positions:
form = MembershipForm(
event=self.request.event,
memberships=memberships,
position=p,
prefix=f"membership-{p.id}",
initial={
'membership': str(p.used_membership_id)
},
data=self.request.POST if self.request.method == "POST" else None,
)
forms.append(form)
return forms
def post(self, request):
self.request = request
for f in self.forms:
if not f.is_valid():
messages.error(request, _('Your cart includes a product that requires an active membership to be selected.'))
return self.render()
f.position.used_membership = f.cleaned_data['membership']
try:
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False)
except ValidationError as e:
messages.error(self.request, e.message)
self.render()
else:
for f in self.forms:
f.position.save(update_fields=['used_membership'])
return redirect(self.get_next_url(request))
def is_completed(self, request, warn=False):
self.request = request
ok = all([p.used_membership_id for p in self.applicable_positions])
if not ok and warn:
messages.error(request, _('Your cart includes a product that requires an active membership to be selected.'))
return ok
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart()
ctx['cart_session'] = self.cart_session
ctx['forms'] = self.forms
return ctx
class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
priority = 40
identifier = "addons"
@@ -486,12 +684,14 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
initial.update({
k: v['initial'] for k, v in overrides.items() if 'initial' in v
})
if self.cart_customer:
initial['email'] = self.cart_customer.email
f = ContactForm(data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
request=self.request,
initial=initial, all_optional=self.all_optional)
if wd.get('email', '') and wd.get('fix', '') == "true":
if wd.get('email', '') and wd.get('fix', '') == "true" or self.cart_customer:
f.fields['email'].disabled = True
for overrides in override_sets:
@@ -504,13 +704,31 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
return f
def get_question_override_sets(self, cart_position):
return [
o = []
if self.cart_customer:
o.append({
'attendee_name_parts': {
'initial': self.cart_customer.name_parts
}
})
o += [
resp for recv, resp in question_form_fields_overrides.send(
self.request.event,
position=cart_position,
request=self.request
)
]
if cart_position.used_membership:
d = {
'initial': cart_position.used_membership.attendee_name_parts
}
if not cart_position.used_membership.membership_type.transferable:
d['disabled'] = True
o.append({
'attendee_name_parts': d
})
return o
@cached_property
def eu_reverse_charge_relevant(self):
@@ -538,6 +756,11 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
wd_initial = {}
initial = dict(wd_initial)
if self.cart_customer:
initial.update({
'name_parts': self.cart_customer.name_parts
})
override_sets = self._contact_override_sets
for overrides in override_sets:
initial.update({
@@ -852,6 +1075,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
ctx['confirm_messages'] = self.confirm_messages
ctx['cart_session'] = self.cart_session
ctx['invoice_address_asked'] = self.address_asked
ctx['customer'] = self.cart_customer
self.cart_session['shown_total'] = str(ctx['cart']['total'])
@@ -928,11 +1152,19 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
for receiver, response in order_meta_from_request.send(sender=request.event, request=request):
meta_info.update(response)
return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None,
[p.id for p in self.positions], self.cart_session.get('email'),
translation.get_language(), self.invoice_address.pk, meta_info,
request.sales_channel.identifier, self.cart_session.get('gift_cards'),
self.cart_session.get('shown_total'))
return self.do(
self.request.event.id,
payment_provider=self.payment_provider.identifier if self.payment_provider else None,
positions=[p.id for p in self.positions],
email=self.cart_session.get('email'),
locale=translation.get_language(),
address=self.invoice_address.pk,
meta_info=meta_info,
sales_channel=request.sales_channel.identifier,
gift_cards=self.cart_session.get('gift_cards'),
shown_total=self.cart_session.get('shown_total'),
customer=self.cart_session.get('customer'),
)
def get_success_message(self, value):
create_empty_cart_id(self.request)
@@ -966,6 +1198,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
DEFAULT_FLOW = (
AddOnsStep,
CustomerStep,
MembershipStep,
QuestionsStep,
PaymentStep,
ConfirmStep

View File

@@ -37,6 +37,9 @@ from itertools import chain
from django import forms
from django.core.exceptions import ValidationError
from django.utils.encoding import force_str
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
@@ -178,3 +181,60 @@ class AddOnVariationField(forms.ChoiceField):
if value == k or text_value == force_str(k):
return True
return False
class MembershipForm(forms.Form):
required_css_class = 'required'
def __init__(self, *args, **kwargs):
self.memberships = kwargs.pop('memberships')
event = kwargs.pop('event')
self.position = kwargs.pop('position')
super().__init__(*args, **kwargs)
ev = self.position.subevent or event
if self.position.variation and self.position.variation.require_membership:
types = self.position.variation.require_membership_types.all()
else:
types = self.position.item.require_membership_types.all()
initial = None
memberships = [
m for m in self.memberships
if m.is_valid(ev) and m.membership_type in types
]
if len(memberships) == 1:
initial = str(memberships[0].pk)
self.fields['membership'] = forms.ChoiceField(
label=_('Membership'),
choices=[
(str(m.pk), self._label_from_instance(m))
for m in memberships
],
initial=initial,
widget=forms.RadioSelect,
)
self.is_empty = not memberships
def _label_from_instance(self, obj):
ds = date_format(obj.date_start, 'SHORT_DATE_FORMAT')
de = date_format(obj.date_end, 'SHORT_DATE_FORMAT')
if obj.membership_type.max_usages is not None:
usages = f'({obj.usages} / {obj.membership_type.max_usages})'
else:
usages = ''
return mark_safe(
f'<strong>{escape(obj.membership_type)}</strong> {usages}<br>'
f'{escape(obj.attendee_name)}<br>'
f'<span class="text-muted">{ds} {de}</span>'
)
def clean(self):
d = super().clean()
if d.get('membership'):
d['membership'] = [m for m in self.memberships if str(m.pk) == d['membership']][0]
return d

View File

@@ -0,0 +1,456 @@
#
# 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/>.
#
import hashlib
import ipaddress
from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password,
)
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix.base.forms.questions import NamePartsFormField
from pretix.base.i18n import get_language_without_region
from pretix.base.models import Customer
from pretix.base.services.mail import mail
from pretix.helpers.http import get_client_ip
from pretix.multidomain.urlreverse import build_absolute_uri
class TokenGenerator(PasswordResetTokenGenerator):
key_salt = "pretix.presale.forms.customer.TokenGenerator"
class AuthenticationForm(forms.Form):
required_css_class = 'required'
email = forms.EmailField(
label=_("E-mail"),
widget=forms.EmailInput(attrs={'autofocus': True})
)
password = forms.CharField(
label=_("Password"),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
error_messages = {
'incomplete': _('You need to fill out all fields.'),
'invalid_login': _(
"We have not found an account with this email address and password."
),
'inactive': _("This account is disabled."),
'unverified': _("You have not yet activated your account and set a password. Please click the link in the "
"email we sent you. Click \"Reset password\" to receive a new email in case you cannot find "
"it again."),
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.customer_cache = None
super().__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get('email')
password = self.cleaned_data.get('password')
if email is not None and password:
try:
u = self.request.organizer.customers.get(email=email)
except Customer.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (django #20760).
Customer().set_password(password)
else:
if u.check_password(password):
self.customer_cache = u
if self.customer_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
)
else:
self.confirm_login_allowed(self.customer_cache)
else:
raise forms.ValidationError(
self.error_messages['incomplete'],
code='incomplete'
)
return self.cleaned_data
def confirm_login_allowed(self, user):
if not user.is_active:
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',
)
if not user.is_verified:
raise forms.ValidationError(
self.error_messages['unverified'],
code='unverified',
)
def get_customer(self):
return self.customer_cache
class RegistrationForm(forms.Form):
required_css_class = 'required'
name_parts = forms.CharField()
email = forms.EmailField(
label=_("E-mail"),
)
error_messages = {
'rate_limit': _("We've received a lot of registration requests from you, please wait 10 minutes before you try again."),
'duplicate': _(
"An account with this email address is already registered. Please try to log in or reset your password "
"instead."
),
'required': _('This field is required.'),
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=True,
scheme=request.organizer.settings.name_scheme,
titles=request.organizer.settings.name_scheme_titles,
label=_('Name'),
)
@cached_property
def ratelimit_key(self):
if not settings.HAS_REDIS:
return None
client_ip = get_client_ip(self.request)
if not client_ip:
return None
try:
client_ip = ipaddress.ip_address(client_ip)
except ValueError:
# Web server not set up correctly
return None
if client_ip.is_private:
# This is the private IP of the server, web server not set up correctly
return None
return 'pretix_customer_registration_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
def clean(self):
email = self.cleaned_data.get('email')
if email is not None:
try:
self.request.organizer.customers.get(email=email)
except Customer.DoesNotExist:
pass
else:
raise forms.ValidationError(
{'email': self.error_messages['duplicate']},
code='duplicate',
)
if not self.cleaned_data.get('email'):
raise forms.ValidationError(
{'email': self.error_messages['required']},
code='incomplete'
)
else:
if self.ratelimit_key:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr(self.ratelimit_key)
rc.expire(self.ratelimit_key, 600)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return self.cleaned_data
def create(self):
customer = self.request.organizer.customers.create(
email=self.cleaned_data['email'],
name_parts=self.cleaned_data['name_parts'],
is_active=True,
is_verified=False,
locale=get_language_without_region(),
)
customer.set_unusable_password()
customer.save()
customer.log_action('pretix.customer.created', {})
ctx = customer.get_email_context()
token = TokenGenerator().make_token(customer)
ctx['url'] = build_absolute_uri(self.request.organizer,
'presale:organizer.customer.activate') + '?id=' + customer.identifier + '&token=' + token
mail(
customer.email,
_('Activate your account at {organizer}').format(organizer=self.request.organizer.name),
self.request.organizer.settings.mail_text_customer_registration,
ctx,
locale=customer.locale,
customer=customer,
organizer=self.request.organizer,
)
return customer
class SetPasswordForm(forms.Form):
required_css_class = 'required'
error_messages = {
'pw_mismatch': _("Please enter the same password twice"),
}
email = forms.EmailField(
label=_('E-mail'),
disabled=True
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
)
def __init__(self, customer=None, *args, **kwargs):
self.customer = customer
kwargs.setdefault('initial', {})
kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs)
def clean(self):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
if password1 and password1 != password2:
raise forms.ValidationError({
'password_repeat': self.error_messages['pw_mismatch'],
}, code='pw_mismatch')
return self.cleaned_data
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
class ResetPasswordForm(forms.Form):
required_css_class = 'required'
error_messages = {
'rate_limit': _("For security reasons, please wait 10 minutes before you try again."),
'unknown': _("A user with this email address is not known in our system."),
}
email = forms.EmailField(
label=_('E-mail'),
)
def __init__(self, request=None, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
def clean_email(self):
if 'email' not in self.cleaned_data:
return
try:
self.customer = self.request.organizer.customers.get(email=self.cleaned_data['email'])
return self.customer.email
except Customer.DoesNotExist:
# Yup, this is an information leak. But it prevents dozens of support requests and even if we didn't
# have it, there'd be an info leak in the registration flow (trying to sign up for an account, which fails
# if the email address already exists).
raise forms.ValidationError(self.error_messages['unknown'], code='unknown')
def clean(self):
d = super().clean()
if d.get('email') and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwreset_customer_%s' % self.customer.pk)
rc.expire('pretix_pwreset_customer_%s' % self.customer.pk, 600)
if cnt > 2:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
return d
class ChangePasswordForm(forms.Form):
required_css_class = 'required'
error_messages = {
'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
}
email = forms.EmailField(
label=_('E-mail'),
disabled=True
)
password_current = forms.CharField(
label=_('Your current password'),
widget=forms.PasswordInput,
required=True
)
password = forms.CharField(
label=_('New password'),
widget=forms.PasswordInput,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput(attrs={'minlength': '8', 'autocomplete': 'new-password'}),
)
def __init__(self, customer, *args, **kwargs):
self.customer = customer
kwargs.setdefault('initial', {})
kwargs['initial']['email'] = self.customer.email
super().__init__(*args, **kwargs)
def clean(self):
password1 = self.cleaned_data.get('password', '')
password2 = self.cleaned_data.get('password_repeat')
if password1 and password1 != password2:
raise forms.ValidationError({
'password_repeat': self.error_messages['pw_mismatch'],
}, code='pw_mismatch')
return self.cleaned_data
def clean_password(self):
password1 = self.cleaned_data.get('password', '')
if validate_password(password1, user=self.customer) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1
def clean_password_current(self):
old_pw = self.cleaned_data.get('password_current')
if old_pw and settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.customer.pk)
rc.expire('pretix_pwchange_customer_%s' % self.customer.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if old_pw and not check_password(old_pw, self.customer.password):
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],
code='pw_current_wrong',
)
class ChangeInfoForm(forms.ModelForm):
required_css_class = 'required'
error_messages = {
'pw_current_wrong': _("The current password you entered was not correct."),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
'duplicate': _("An account with this email address is already registered."),
}
password_current = forms.CharField(
label=_('Your current password'),
widget=forms.PasswordInput,
help_text=_('Only required if you change your email address'),
required=False
)
class Meta:
model = Customer
fields = ('name_parts', 'email')
def __init__(self, request=None, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=True,
scheme=request.organizer.settings.name_scheme,
titles=request.organizer.settings.name_scheme_titles,
label=_('Name'),
)
def clean_password_current(self):
old_pw = self.cleaned_data.get('password_current')
if old_pw:
if settings.HAS_REDIS:
from django_redis import get_redis_connection
rc = get_redis_connection("redis")
cnt = rc.incr('pretix_pwchange_customer_%s' % self.instance.pk)
rc.expire('pretix_pwchange_customer_%s' % self.instance.pk, 300)
if cnt > 10:
raise forms.ValidationError(
self.error_messages['rate_limit'],
code='rate_limit',
)
if not check_password(old_pw, self.instance.password):
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],
code='pw_current_wrong',
)
return "***valid***"
def clean(self):
email = self.cleaned_data.get('email')
password_current = self.cleaned_data.get('password_current')
if email != self.instance.email and not password_current:
raise forms.ValidationError(
self.error_messages['pw_current_wrong'],
code='pw_current_wrong',
)
if email is not None:
try:
self.request.organizer.customers.exclude(pk=self.instance.pk).get(email=email)
except Customer.DoesNotExist:
pass
else:
raise forms.ValidationError(
self.error_messages['duplicate'],
code='duplicate',
)
return self.cleaned_data

View File

@@ -120,7 +120,10 @@ class CheckoutFieldRenderer(FieldRenderer):
def add_label(self, html):
label = self.get_label()
if hasattr(self.field.field, '_required'):
if hasattr(self.field.field, '_show_required'):
# e.g. payment settings forms where a field is only required if the payment provider is active
required = self.field.field._show_required
elif hasattr(self.field.field, '_required'):
# e.g. payment settings forms where a field is only required if the payment provider is active
required = self.field.field._required
else:

View File

@@ -34,21 +34,24 @@
</div>
{% endif %}
<div class="container page-header-links {% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}page-header-links-outside{% endif %}">
{% if event.settings.locales|length > 1 %}
{% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
{% if event.settings.theme_color_background|upper != "#FFFFFF" or event.settings.logo_image_large %}
<div class="pull-right header-part flip hidden-print">
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow" lang="{{ l.code }}" hreflang="{{ l.code }}">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% if event.settings.locales|length > 1 %}
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow" lang="{{ l.code }}" hreflang="{{ l.code }}">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% endif %}
{% include "pretixpresale/fragment_login_status.html" %}
</div>
{% endif %}
{% endif %}
{% if request.event.settings.organizer_link_back %}
<div class="pull-left header-part flip hidden-print">
<a href="{% eventurl request.organizer "presale:organizer.index" %}">
<a href="{% abseventurl request.organizer "presale:organizer.index" %}">
&laquo; {% blocktrans trimmed with name=request.organizer.name %}
Show all events of {{ name }}
{% endblocktrans %}
@@ -80,15 +83,18 @@
</h1>
{% endif %}
</div>
{% if event.settings.locales|length > 1 %}
{% if event.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
{% if event.settings.theme_color_background|upper == "#FFFFFF" and not event.settings.logo_image_large %}
<div class="{% if not event_logo or not event.settings.logo_image_large %}pull-right flip{% endif %} loginbox hidden-print">
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% if event.settings.locales|length > 1 %}
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}{% if request.META.QUERY_STRING %}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% endif %}
{% include "pretixpresale/fragment_login_status.html" %}
</div>
{% endif %}
{% endif %}

View File

@@ -139,6 +139,12 @@
</h3>
</div>
<div class="panel-body">
{% if customer %}
<dl class="dl-horizontal">
<dt>{% trans "Customer account" %}</dt>
<dd>{{ customer.email }}<br>{{ customer.name }}<br>#{{ customer.identifier }}</dd>
</dl>
{% endif %}
{% if not asked and event.settings.invoice_name_required %}
<dl class="dl-horizontal">
<dt>{% trans "Name" %}</dt>

View File

@@ -0,0 +1,141 @@
{% extends "pretixpresale/event/checkout_base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load eventurl %}
{% load rich_text %}
{% block inner %}
<form method="post">
{% csrf_token %}
<div class="panel-group" id="customer">
<div class="panel panel-default">
<label class="accordion-radio">
<div class="panel-heading">
<h4 class="panel-title">
<input type="radio" name="customer_mode" value="login"
data-parent="#customer"
{% if selected == "login" %}checked="checked"{% endif %}
data-toggle="radiocollapse" data-target="#customer_login"/>
<strong>
{% trans "Log in with a customer account" %}
</strong>
</h4>
</div>
</label>
<div id="customer_login"
class="panel-collapse collapsed {% if selected == "login" %}in{% endif %}">
<div class="panel-body form-horizontal">
{% if customer %}
<p>
{% blocktrans trimmed with org=request.organizer.name %}
You are currently logged in with the following credentials.
{% endblocktrans %}
</p>
<dl class="dl-horizontal">
<dt>{% trans "Email" %}</dt>
<dd>
{{ customer.email }}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
<dt>{% trans "Customer ID" %}</dt>
<dd>
#{{ customer.identifier }}
</dd>
</dl>
{% else %}
<p>
{% blocktrans trimmed with org=request.organizer.name %}
If you created a customer account at {{ org }} before, you can log in now and connect
your order to your account. This will allow you to see all your orders in one place
and access them at any time.
{% endblocktrans %}
</p>
{% bootstrap_form login_form layout="checkout" %}
<div class="row">
<div class="col-md-offset-3 col-md-9">
<a
href="{% abseventurl request.organizer "presale:organizer.customer.resetpw" %}"
target="_blank">
{% trans "Reset password" %}
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="panel panel-default">
<label class="accordion-radio">
<div class="panel-heading">
<h4 class="panel-title">
<input type="radio" name="customer_mode" value="register"
data-parent="#customer"
{% if selected == "register" %}checked="checked"{% endif %}
data-toggle="radiocollapse" data-target="#customer_register"/>
<strong>
{% trans "Create a new customer account" %}
</strong>
</h4>
</div>
</label>
<div id="customer_register"
class="panel-collapse collapsed {% if selected == "register" %}in{% endif %}">
<div class="panel-body form-horizontal">
{% bootstrap_form register_form layout="checkout" %}
<p>
{% blocktrans trimmed with org=request.organizer.name %}
We will send you an email with a link to activate your account and set a password, so
you can use the account for future orders at {{ org }}. You can still go ahead with this
purchase before you received the email.
{% endblocktrans %}
</p>
</div>
</div>
</div>
{% if guest_allowed %}
<div class="panel panel-default">
<label class="accordion-radio">
<div class="panel-heading">
<h4 class="panel-title">
<input type="radio" name="customer_mode" value="guest"
data-parent="#customer"
{% if selected == "guest" %}checked="checked"{% endif %}
data-toggle="radiocollapse" data-target="#customer_guest"/>
<strong>
{% trans "Continue as a guest" %}
</strong>
</h4>
</div>
</label>
<div id="customer_guest"
class="panel-collapse collapsed {% if selected == "guest" %}in{% endif %}">
<div class="panel-body form-horizontal">
<p>
{% blocktrans trimmed %}
You are not required to create an account. If you proceed as a guest, you will be able
to access the details and status of your order any time through the secret link we will
send you via email once the order is complete.
{% endblocktrans %}
</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4 col-sm-6">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,97 @@
{% extends "pretixpresale/event/checkout_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load rich_text %}
{% block inner %}
<p>{% trans "Some of the products in your cart can only be purchased if there is an active membership on your account." %}</p>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% for form in forms %}
<details class="panel panel-default" open>
<summary class="panel-heading">
<h4 class="panel-title">
<strong>{{ form.position.item.name }}{% if form.position.variation %}
{{ form.position.variation }}
{% endif %}</strong>
<i class="fa fa-angle-down collapse-indicator" aria-hidden="true"></i>
</h4>
</summary>
<div>
<div class="panel-body questions-form">
{% if form.position.seat %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Seat" %}
</label>
<div class="col-md-9 form-control-text">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
<path
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
</svg>
{{ form.position.seat }}
</div>
</div>
{% endif %}
{% if form.position.addons.all %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Selected add-ons" %}
</label>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{% for a in form.position.addons.all %}
<li>{{ a.item.name }}{% if a.variation %} {{ a.variation.value }}{% endif %}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if form.position.subevent %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Date" context "subevent" %}
</label>
<div class="col-md-9 form-control-text">
<ul class="addon-list">
{{ form.position.subevent.name }} &middot; {{ form.position.subevent.get_date_range_display }}
{% if form.position.event.settings.show_times %}
<span data-time="{{ form.position.subevent.date_from.isoformat }}" data-timezone="{{ request.event.timezone }}">
<span class="fa fa-clock-o" aria-hidden="true"></span>
{{ form.position.subevent.date_from|date:"TIME_FORMAT" }}
</span>
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% if form.is_empty %}
<div class="alert alert-danger">
{% trans "Your account does not include an active membership that allows you to buy this product." %}
{% trans "You will not be able to continue." %}
</div>
<div class="sr-only">
{% bootstrap_form form layout="checkout" %}
</div>
{% else %}
{% bootstrap_form form layout="checkout" %}
{% endif %}
</div>
</div>
</details>
{% endfor %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4 col-sm-6">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Continue" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -34,6 +34,9 @@
</span>
{% endif %}
{% endif %}
{% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span> {{ line.used_membership }}
{% endif %}
{% if line.issued_gift_cards %}
<dl>

View File

@@ -0,0 +1,23 @@
{% load i18n %}
{% load eventurl %}
{% if request.organizer.settings.customer_accounts %}
<nav class="loginstatus" aria-label="{% trans "customer account" %}">
{% if request.customer %}
<a href="{% abseventurl request.organizer "presale:organizer.customer.profile" %}"
aria-label="{% trans "View customer account" %}" data-placement="bottom"
title="{% trans "View user profile" %}" data-toggle="tooltip">
<span class="fa fa-user" aria-hidden="true"></span>
{{ request.customer.name|default:request.customer.email }}</a>
<a href="{% abseventurl request.organizer "presale:organizer.customer.logout" %}?next={{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}"
aria-label="{% trans "Log out" %}" data-toggle="tooltip" data-placement="left"
title="{% trans "Log out" %}">
<span class="fa fa-sign-out" aria-hidden="true"></span>
</a>
{% else %}
<a href="{% abseventurl request.organizer "presale:organizer.customer.login" %}?next={{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}">
{% trans "Log in" %}</a>
{% endif %}
</nav>
{% endif %}

View File

@@ -8,16 +8,19 @@
{% block title %}{% endblock %}{% if url_name != "organizer.index" %} :: {% endif %}{{ organizer.name }}
{% endblock %}
{% block above %}
{% if organizer.settings.locales|length > 1 %}
{% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
{% if organizer.settings.theme_color_background|upper != "#FFFFFF" or organizer.settings.organizer_logo_image_large %}
<div class="container page-header-links">
<div class="pull-right header-part flip">
<div class="locales">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</div>
{% if organizer.settings.locales|length > 1 %}
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% endif %}
{% include "pretixpresale/fragment_login_status.html" %}
</div>
</div>
{% endif %}
@@ -40,15 +43,18 @@
<h1><a href="{% eventurl organizer "presale:organizer.index" %}">{{ organizer.name }}</a></h1>
{% endif %}
</div>
{% if organizer.settings.locales|length > 1 %}
{% if organizer.settings.locales|length > 1 or request.organizer.settings.customer_accounts %}
{% if organizer.settings.theme_color_background|upper == "#FFFFFF" and not organizer.settings.organizer_logo_image_large %}
<div class="{% if not organizer_logo or not organizer.settings.organizer_logo_image_large %}pull-right flip{% endif %} loginbox">
<div class="locales">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</div>
{% if organizer.settings.locales|length > 1 %}
<nav class="locales" aria-label="{% trans "select language" %}">
{% for l in languages %}
<a href="{% url "presale:locale.set" %}?locale={{ l.code }}&next={{ request.path }}%3F{{ request.META.QUERY_STRING|urlencode }}" class="{% if l.code == request.LANGUAGE_CODE %}active{% endif %}" rel="nofollow">
{{ l.name_local }}</a>
{% endfor %}
</nav>
{% endif %}
{% include "pretixpresale/fragment_login_status.html" %}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,27 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Account information" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed %}
Update your account information
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Save" %}
</button>
</div>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Log in" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed with org=request.organizer.name %}
Sign in to your account at {{ org }}
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Log in" %}
</button>
</div>
<div class="row">
<div class="col-md-6">
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.register" %}">
{% trans "Create account" %}
</a>
</div>
<div class="col-md-6">
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.resetpw" %}">
{% trans "Reset password" %}
</a>
</div>
</div>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -0,0 +1,98 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Your membership" %}{% endblock %}
{% block content %}
<h2>
{% trans "Your membership" %}
</h2>
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "Membership type" %}</dt>
<dd>{{ membership.membership_type.name }}</dd>
<dt>{% trans "Valid from" %}</dt>
<dd>{{ membership.date_start|date:"SHORT_DATETIME_FORMAT" }}
<dt>{% trans "Valid until" %}</dt>
<dd>{{ membership.date_end|date:"SHORT_DATETIME_FORMAT" }}
<dt>{% trans "Attendee name" %}</dt>
<dd>{{ membership.attendee_name }}
<dt>{% trans "Maximum usages" %}</dt>
<dd>{{ membership.membership_type.max_usages|default_if_none:"" }}</dd>
</dl>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Usages" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Product" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Status" %}</th>
<th class="text-right"></th>
</tr>
</thead>
<tbody>
{% for op in usages %}
<tr>
<td>
<strong>
{{ op.order.code }}-{{ op.positionid }}
</strong>
{% if op.order.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ op.order.event }}
{% if op.subevent %}
<br>
{{ op.subevent|default:"" }}
{% endif %}
</td>
<td>
{{ op.item.name }}
{% if op.variation %} {{ op.variation }}{% endif %}
</td>
<td>
{{ op.order.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td class="text-right flip">
{% if op.canceled %}
<span class="label label-danger">
<span class="fa fa-times"></span>
{% trans "Canceled" %}
</span>
{% else %}
{% include "pretixcontrol/orders/fragment_order_status.html" with order=op.order %}
{% endif %}
</td>
<td class="text-right flip">
<a href="{% abseventurl op.order.event "presale:event.order" order=op.order.code secret=op.order.secret %}"
target="_blank"
class="btn btn-default">
{% trans "Details" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Password reset" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed %}
Set a new password for your account
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Save" %}
</button>
</div>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -0,0 +1,158 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load money %}
{% load bootstrap3 %}
{% block title %}{% trans "Your account" %}{% endblock %}
{% block content %}
<h2>
{% trans "Your account" %}
</h2>
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Account information" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>{{ customer.email }}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
</dl>
<div class="text-right">
<a href="{% eventurl request.organizer "presale:organizer.customer.change" %}"
class="btn btn-default">
{% trans "Change account information" %}
</a>
<a href="{% eventurl request.organizer "presale:organizer.customer.password" %}"
class="btn btn-default">
{% trans "Change password" %}
</a>
</div>
</div>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Memberships" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Membership type" %}</th>
<th>{% trans "Valid from" %}</th>
<th>{% trans "Valid until" %}</th>
<th>{% trans "Attendee name" %}</th>
<th>{% trans "Usages" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in memberships %}
<tr>
<td>
{{ m.membership_type.name }}
</td>
<td>
{{ m.date_start|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.date_end|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{{ m.attendee_name }}
</td>
<td>
<div class="quotabox">
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-{{ m.percent }}">
</div>
</div>
<div class="numbers">
{{ m.usages }} /
{{ m.membership_type.max_usages|default_if_none:"∞" }}
</div>
</div>
</td>
<td class="text-right flip">
<a href="{% abseventurl request.organizer "presale:organizer.customer.membership" id=m.id %}"
data-toggle="tooltip"
title="{% trans "Details" %}"
class="btn btn-default">
<i class="fa fa-list"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Orders" %}
</h3>
</div>
<table class="panel-body table table-hover">
<thead>
<tr>
<th>{% trans "Order code" %}</th>
<th>{% trans "Event" %}</th>
<th>{% trans "Order date" %}</th>
<th class="text-right">{% trans "Order total" %}</th>
<th class="text-right">{% trans "Positions" %}</th>
<th class="text-right">{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in orders %}
<tr>
<td>
<strong>
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}" target="_blank">
{{ o.code }}
</a>
</strong>
{% if o.testmode %}
<span class="label label-warning">{% trans "TEST MODE" %}</span>
{% endif %}
</td>
<td>
{{ o.event }}
</td>
<td>
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.customer_id != customer.pk %}
<span class="fa fa-link text-muted"
data-toggle="tooltip"
title="{% trans "Matched to the account based on the email address." %}"
></span>
{% endif %}
</td>
<td class="text-right flip">
{{ o.total|money:o.event.currency }}
</td>
<td class="text-right flip">{{ o.count_positions|default_if_none:"0" }}</td>
<td class="text-right flip">{% include "pretixpresale/event/fragment_order_status.html" with order=o event=o.event %}</td>
<td class="text-right flip">
<a href="{% abseventurl o.event "presale:event.order" order=o.code secret=o.secret %}"
target="_blank"
class="btn btn-default">
{% trans "Details" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Registration" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed with org=request.organizer.name %}
Create a new account at {{ org }}
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Create account" %}
</button>
</div>
<a class="btn btn-link btn-block" href="{% eventurl request.organizer "presale:organizer.customer.login" %}">
{% trans "Log in to an existing account" %}
</a>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Password reset" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed %}
Password reset
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Request a new password" %}
</button>
</div>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "pretixpresale/organizers/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Password reset" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>
{% blocktrans trimmed %}
Set a new password for your account
{% endblocktrans %}
</h2>
<form action="" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-lg btn-block">
{% trans "Save" %}
</button>
</div>
</form>
</div>
</div>
<p>&nbsp;</p>
{% endblock %}

View File

@@ -37,6 +37,7 @@ from django.views.decorators.csrf import csrf_exempt
import pretix.presale.views.cart
import pretix.presale.views.checkout
import pretix.presale.views.customer
import pretix.presale.views.event
import pretix.presale.views.locale
import pretix.presale.views.order
@@ -165,6 +166,17 @@ organizer_patterns = [
url(r'^widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
name='organizer.widget.productlist'),
url(r'^widget/v1.css$', pretix.presale.views.widget.widget_css, name='organizer.widget.css'),
url(r'^account/login$', pretix.presale.views.customer.LoginView.as_view(), name='organizer.customer.login'),
url(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
url(r'^account/register$', pretix.presale.views.customer.RegistrationView.as_view(), name='organizer.customer.register'),
url(r'^account/pwreset$', pretix.presale.views.customer.ResetPasswordView.as_view(), name='organizer.customer.resetpw'),
url(r'^account/pwrecover$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.recoverpw'),
url(r'^account/activate$', pretix.presale.views.customer.SetPasswordView.as_view(), name='organizer.customer.activate'),
url(r'^account/password$', pretix.presale.views.customer.ChangePasswordView.as_view(), name='organizer.customer.password'),
url(r'^account/change$', pretix.presale.views.customer.ChangeInformationView.as_view(), name='organizer.customer.change'),
url(r'^account/confirmchange$', pretix.presale.views.customer.ConfirmChangeView.as_view(), name='organizer.customer.change.confirm'),
url(r'^account/membership/(?P<id>\d+)/$', pretix.presale.views.customer.MembershipUsageView.as_view(), name='organizer.customer.membership'),
url(r'^account/$', pretix.presale.views.customer.ProfileView.as_view(), name='organizer.customer.profile'),
]
locale_patterns = [

View File

@@ -39,15 +39,19 @@ from urllib.parse import urljoin
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.middleware.csrf import rotate_token
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.urls import resolve
from django.utils.crypto import constant_time_compare
from django.utils.functional import SimpleLazyObject
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.defaults import permission_denied
from django_scopes import scope
from pretix.base.middleware import LocaleMiddleware
from pretix.base.models import Event, Organizer
from pretix.base.models import Customer, Event, Organizer
from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
)
@@ -56,8 +60,96 @@ from pretix.presale.signals import process_request, process_response
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
def get_customer(request):
if not hasattr(request, '_cached_customer'):
session_key = f'customer_auth_id:{request.organizer.pk}'
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
with scope(organizer=request.organizer):
try:
customer = request.organizer.customers.get(
is_active=True, is_verified=True,
pk=request.session[session_key]
)
except (Customer.DoesNotExist, KeyError):
request._cached_customer = None
else:
session_hash = request.session.get(hash_session_key)
session_hash_verified = session_hash and constant_time_compare(
session_hash,
customer.get_session_auth_hash()
)
if session_hash_verified:
request._cached_customer = customer
else:
request.session.flush()
request._cached_customer = None
return request._cached_customer
def update_customer_session_auth_hash(request, customer):
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
session_auth_hash = customer.get_session_auth_hash()
request.session.cycle_key()
request.session[hash_session_key] = session_auth_hash
def add_customer_to_request(request):
request.customer = SimpleLazyObject(lambda: get_customer(request))
def customer_login(request, customer):
session_key = f'customer_auth_id:{request.organizer.pk}'
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
session_auth_hash = customer.get_session_auth_hash()
if session_key in request.session:
if request.session[session_key] != customer.pk or (
not constant_time_compare(request.session.get(hash_session_key, ''), session_auth_hash)):
# To avoid reusing another user's session, create a new, empty
# session if the existing session corresponds to a different
# authenticated user.
request.session.flush()
else:
request.session.cycle_key()
request.session[session_key] = customer.pk
request.session[hash_session_key] = session_auth_hash
request.customer = customer
customer.last_login = now()
customer.save(update_fields=['last_login'])
rotate_token(request)
def customer_logout(request):
session_key = f'customer_auth_id:{request.organizer.pk}'
hash_session_key = f'customer_auth_hash:{request.organizer.pk}'
# Remove user session
customer_id = request.session.pop(session_key, None)
request.session.pop(hash_session_key, None)
# Remove carts tied to this user
carts = request.session.get('carts', {})
for k, v in list(carts.items()):
if v.get('customer') == customer_id:
carts.pop(k)
request.session['carts'] = carts
# Cycle session key and CSRF token
request.session.cycle_key()
rotate_token(request)
request.customer = None
request._cached_customer = None
@scope(organizer=None)
def _detect_event(request, require_live=True, require_plugin=None):
if hasattr(request, '_event_detected'):
return
@@ -132,6 +224,9 @@ def _detect_event(request, require_live=True, require_plugin=None):
r['Access-Control-Allow-Origin'] = '*'
return r
if not hasattr(request, 'customer'):
add_customer_to_request(request)
if hasattr(request, 'event'):
# Restrict locales to the ones available for this event
LocaleMiddleware().process_request(request)

View File

@@ -46,7 +46,7 @@ from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, InvoiceAddress, ItemAddOn, OrderPosition, Question,
CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderPosition, Question,
QuestionAnswer, QuestionOption,
)
from pretix.base.services.cart import get_fees
@@ -91,6 +91,14 @@ class CartMixin:
from pretix.presale.views.cart import cart_session
return cart_session(self.request)
@cached_property
def cart_customer(self):
if self.cart_session.get('customer_mode', 'guest') == 'login':
try:
return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
except Customer.DoesNotExist:
return
@cached_property
def invoice_address(self):
return cached_invoice_address(self.request)
@@ -273,7 +281,7 @@ def get_cart(request):
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
).select_related(
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
'item__tax_rule', 'addon_to'
'item__tax_rule', 'addon_to', 'used_membership', 'used_membership__membership_type'
).select_related(
'addon_to'
).prefetch_related(

View File

@@ -0,0 +1,472 @@
#
# 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 urllib.parse import quote
from django.contrib import messages
from django.core.signing import BadSignature, dumps, loads
from django.db import transaction
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, ListView, View
from pretix.base.models import Customer, Order, OrderPosition
from pretix.base.services.mail import mail
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.presale.forms.customer import (
AuthenticationForm, ChangeInfoForm, ChangePasswordForm, RegistrationForm,
ResetPasswordForm, SetPasswordForm, TokenGenerator,
)
from pretix.presale.utils import (
customer_login, customer_logout, update_customer_session_auth_hash,
)
class RedirectBackMixin:
redirect_field_name = 'next'
def get_redirect_url(self):
"""Return the user-originating redirect URL if it's safe."""
redirect_to = self.request.POST.get(
self.redirect_field_name,
self.request.GET.get(self.redirect_field_name, '')
)
url_is_safe = url_has_allowed_host_and_scheme(
url=redirect_to,
allowed_hosts=None,
require_https=self.request.is_secure(),
)
return redirect_to if url_is_safe else ''
class LoginView(RedirectBackMixin, FormView):
"""
Display the login form and handle the login action.
"""
form_class = AuthenticationForm
template_name = 'pretixpresale/organizers/customer_login.html'
redirect_authenticated_user = True
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
if self.redirect_authenticated_user and self.request.customer:
redirect_to = self.get_success_url()
if redirect_to == self.request.path:
raise ValueError(
"Redirection loop for authenticated user detected. Check that "
"your LOGIN_REDIRECT_URL doesn't point to a login page."
)
return HttpResponseRedirect(redirect_to)
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
def get_success_url(self):
url = self.get_redirect_url()
return url or eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
def form_valid(self, form):
"""Security check complete. Log the user in."""
customer_login(self.request, form.get_customer())
return HttpResponseRedirect(self.get_success_url())
class LogoutView(View):
redirect_field_name = 'next'
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
customer_logout(request)
next_page = self.get_next_page()
return HttpResponseRedirect(next_page)
def get_next_page(self):
next_page = eventreverse(self.request.organizer, 'presale:organizer.index', kwargs={})
if (self.redirect_field_name in self.request.POST or
self.redirect_field_name in self.request.GET):
next_page = self.request.POST.get(
self.redirect_field_name,
self.request.GET.get(self.redirect_field_name)
)
url_is_safe = url_has_allowed_host_and_scheme(
url=next_page,
allowed_hosts=None,
require_https=self.request.is_secure(),
)
# Security check -- Ensure the user-originating redirection URL is
# safe.
if not url_is_safe:
next_page = self.request.path
return next_page
class RegistrationView(RedirectBackMixin, FormView):
form_class = RegistrationForm
template_name = 'pretixpresale/organizers/customer_registration.html'
redirect_authenticated_user = True
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
if self.redirect_authenticated_user and self.request.customer:
redirect_to = self.get_success_url()
if redirect_to == self.request.path:
raise ValueError(
"Redirection loop for authenticated user detected. Check that "
"your LOGIN_REDIRECT_URL doesn't point to a login page."
)
return HttpResponseRedirect(redirect_to)
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
def get_success_url(self):
url = self.get_redirect_url()
return url or eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
def form_valid(self, form):
with transaction.atomic():
form.create()
messages.success(
self.request,
_('Your account has been created. Please follow the link in the email we sent you to activate your '
'account and choose a password.')
)
return HttpResponseRedirect(self.get_success_url())
class SetPasswordView(FormView):
form_class = SetPasswordForm
template_name = 'pretixpresale/organizers/customer_setpassword.html'
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
try:
self.customer = request.organizer.customers.get(identifier=self.request.GET.get('id'))
except Customer.DoesNotExist:
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')):
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['customer'] = self.customer
return kwargs
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
def form_valid(self, form):
with transaction.atomic():
self.customer.set_password(form.cleaned_data['password'])
self.customer.is_verified = True
self.customer.save()
self.customer.log_action('pretix.customer.password.set', {})
messages.success(
self.request,
_('Your new password has been set! You can now use it to log in.'),
)
return HttpResponseRedirect(self.get_success_url())
class ResetPasswordView(FormView):
form_class = ResetPasswordForm
template_name = 'pretixpresale/organizers/customer_resetpw.html'
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
def form_valid(self, form):
customer = form.customer
customer.log_action('pretix.customer.password.resetrequested', {})
ctx = customer.get_email_context()
token = TokenGenerator().make_token(customer)
ctx['url'] = build_absolute_uri(self.request.organizer,
'presale:organizer.customer.recoverpw') + '?id=' + customer.identifier + '&token=' + token
mail(
customer.email,
_('Set a new password for your account at {organizer}').format(organizer=self.request.organizer.name),
self.request.organizer.settings.mail_text_customer_reset,
ctx,
locale=customer.locale,
customer=customer,
organizer=self.request.organizer,
)
messages.success(
self.request,
_('We\'ve sent you an email with further instructions on resetting your password.')
)
return HttpResponseRedirect(self.get_success_url())
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
return kwargs
class CustomerRequiredMixin:
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
if not getattr(request, 'customer', None):
return redirect(
eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={}) +
'?next=' + quote(self.request.path_info + '?' + self.request.GET.urlencode())
)
return super().dispatch(request, *args, **kwargs)
class ProfileView(CustomerRequiredMixin, ListView):
template_name = 'pretixpresale/organizers/customer_profile.html'
context_object_name = 'orders'
paginate_by = 20
def get_queryset(self):
qs = Order.objects.filter(
Q(customer=self.request.customer)
| Q(email__iexact=self.request.customer.email)
# This is safe because we only let customers with verified emails log in
).select_related('event').order_by('-datetime')
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['customer'] = self.request.customer
ctx['memberships'] = self.request.customer.memberships.with_usages().select_related(
'membership_type', 'granted_in', 'granted_in__order', 'granted_in__order__event'
)
ctx['is_paginated'] = True
for m in ctx['memberships']:
if m.membership_type.max_usages:
m.percent = int(m.usages / m.membership_type.max_usages * 100)
else:
m.percent = 0
s = OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(k=Count('id')).values('k')
annotated = {
o['pk']: o
for o in
Order.annotate_overpayments(Order.objects, sums=True).filter(
pk__in=[o.pk for o in ctx['orders']]
).annotate(
pcnt=Subquery(s, output_field=IntegerField()),
).values(
'pk', 'pcnt',
)
}
for o in ctx['orders']:
if o.pk not in annotated:
continue
o.count_positions = annotated.get(o.pk)['pcnt']
return ctx
class MembershipUsageView(CustomerRequiredMixin, ListView):
template_name = 'pretixpresale/organizers/customer_membership.html'
context_object_name = 'usages'
paginate_by = 20
@cached_property
def membership(self):
return get_object_or_404(
self.request.customer.memberships,
pk=self.kwargs.get('id')
)
def get_queryset(self):
return self.membership.orderposition_set.select_related(
'order', 'order__event', 'subevent', 'item', 'variation',
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['membership'] = self.membership
ctx['is_paginated'] = True
return ctx
class ChangePasswordView(CustomerRequiredMixin, FormView):
template_name = 'pretixpresale/organizers/customer_password.html'
form_class = ChangePasswordForm
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
@transaction.atomic()
def form_valid(self, form):
customer = form.customer
customer.log_action('pretix.customer.password.set', {})
customer.set_password(form.cleaned_data['password'])
customer.save()
messages.success(self.request, _('Your changes have been saved.'))
update_customer_session_auth_hash(self.request, customer)
return HttpResponseRedirect(self.get_success_url())
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['customer'] = self.request.customer
return kwargs
class ChangeInformationView(CustomerRequiredMixin, FormView):
template_name = 'pretixpresale/organizers/customer_info.html'
form_class = ChangeInfoForm
@method_decorator(sensitive_post_parameters())
@method_decorator(csrf_protect)
@method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
if self.request.customer:
self.initial_email = self.request.customer.email
return super().dispatch(request, *args, **kwargs)
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})
def form_valid(self, form):
if form.cleaned_data['email'] != self.initial_email:
new_email = form.cleaned_data['email']
form.cleaned_data['email'] = form.instance.email = self.initial_email
ctx = form.instance.get_email_context()
ctx['url'] = build_absolute_uri(
self.request.organizer,
'presale:organizer.customer.change.confirm'
) + '?token=' + dumps({
'customer': form.instance.pk,
'email': new_email
}, salt='pretix.presale.views.customer.ChangeInformationView')
mail(
new_email,
_('Confirm email address for your account at {organizer}').format(organizer=self.request.organizer.name),
self.request.organizer.settings.mail_text_customer_email_change,
ctx,
locale=form.instance.locale,
customer=form.instance,
organizer=self.request.organizer,
)
messages.success(self.request, _('Your changes have been saved. We\'ve sent you an email with a link to update your '
'email address. The email address of your account will be changed as soon as you '
'click that link.'))
else:
messages.success(self.request, _('Your changes have been saved.'))
with transaction.atomic():
form.save()
d = dict(form.cleaned_data)
del d['email']
self.request.customer.log_action('pretix.customer.changed', d)
update_customer_session_auth_hash(self.request, form.instance)
return HttpResponseRedirect(self.get_success_url())
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['request'] = self.request
kwargs['instance'] = self.request.customer
return kwargs
class ConfirmChangeView(View):
template_name = 'pretixpresale/organizers/customer_info.html'
def get(self, request, *args, **kwargs):
if not request.organizer.settings.customer_accounts:
raise Http404('Feature not enabled')
try:
data = loads(request.GET.get('token', ''), salt='pretix.presale.views.customer.ChangeInformationView', max_age=3600 * 24)
except BadSignature:
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
try:
customer = request.organizer.customers.get(pk=data.get('customer'))
except Customer.DoesNotExist:
messages.error(request, _('You clicked an invalid link.'))
return HttpResponseRedirect(self.get_success_url())
with transaction.atomic():
customer.email = data['email']
customer.save()
customer.log_action('pretix.customer.changed', {
'email': data['email']
})
messages.success(request, _('Your email address has been updated.'))
if customer == request.customer:
update_customer_session_auth_hash(self.request, customer)
return HttpResponseRedirect(self.get_success_url())
def get_success_url(self):
return eventreverse(self.request.organizer, 'presale:organizer.customer.profile', kwargs={})

View File

@@ -731,6 +731,14 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
for k in override:
# We don't want initial values to be modified, they should come from the order directly
override[k].pop('initial', None)
if order_position.used_membership and not order_position.used_membership.membership_type.transferable:
override_sets.append({
'attendee_name_parts': {
'disabled': True
}
})
return override_sets
def post(self, request, *args, **kwargs):

View File

@@ -287,6 +287,7 @@ CACHE_TICKETS_HOURS = config.getint('cache', 'tickets', fallback=24 * 3)
ENTROPY = {
'order_code': config.getint('entropy', 'order_code', fallback=5),
'customer_identifier': config.getint('entropy', 'customer_identifier', fallback=7),
'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32),
'voucher_code': config.getint('entropy', 'voucher_code', fallback=16),
'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12),

View File

@@ -289,6 +289,9 @@ var form_handlers = function (el) {
dependency = $($(this).attr("data-display-dependency")),
update = function (ev) {
var enabled = (dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val();
if (dependent.is("[data-inverse]")) {
enabled = !enabled;
}
var $toggling = dependent;
if (dependent.get(0).tagName.toLowerCase() !== "div") {
$toggling = dependent.closest('.form-group');
@@ -304,8 +307,8 @@ var form_handlers = function (el) {
}
};
update();
dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("change", update);
dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update);
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update);
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update);
});
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {
@@ -702,11 +705,13 @@ $(function () {
);
});
$(".propagated-settings-box").find("input, textarea, select").not("[disabled]")
.attr("data-propagated-locked", "true").prop("disabled", true);
$(".propagated-settings-box button[data-action=unlink]").click(function (ev) {
var $box = $(this).closest(".propagated-settings-box");
$box.find(".propagated-settings-overlay").fadeOut();
$box.find("input[name=_settings_ignore]").attr("name", "decouple");
$box.find(".propagated-settings-form").removeClass("blurred");
$box.find("[data-propagated-locked]").prop("disabled", false);
$box.removeClass("locked").addClass("unlocked");
ev.preventDefault();
return true;
});

View File

@@ -1,10 +1,16 @@
input[lang] {
background: no-repeat 10px center;
padding-left: 34px;
&[disabled] {
background-color: $input-bg-disabled;
}
}
textarea[lang] {
background: no-repeat 10px 10px;
padding-left: 34px;
&[disabled] {
background-color: $input-bg-disabled;
}
}
pre[lang] {
background: no-repeat 10px 10px;
@@ -23,6 +29,10 @@ div[lang] {
input[lang], textarea[lang], div[lang], pre[lang] {
background: none;
padding-left: 12px;
&[disabled] {
background-color: $input-bg-disabled;
}
}
}

View File

@@ -333,25 +333,27 @@ input[type=number].short {
}
}
.propagated-settings-box {
position: relative;
.propagated-settings-overlay {
background: rgba(255, 255, 255, 0.7);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-align: center;
.propagated-settings-box.locked {
.propagated-settings-form {
opacity: 0.7;
}
.propagated-settings-form.blurred {
-webkit-filter: blur(2px);
-moz-filter: blur(2px);
-ms-filter: blur(2px);
-o-filter: blur(2px);
filter: blur(2px);
.panel-body.help-text {
border-bottom: 1px solid $panel-default-heading-bg;
}
}
.propagated-settings-box.unlocked {
border: 0;
transition: border-width 0.5s linear;
.panel-heading, .panel-body.help-text {
height: 0;
padding: 0;
border: 0;
overflow: hidden;
transition: height 0.5s linear, padding 0.5s linear, border-width 0.5s linear;
}
.panel-body {
padding: 0;
transition: padding 0.5s linear;
}
}
@media (max-width: $screen-sm-max) {

View File

@@ -30,7 +30,7 @@ footer nav {
.js-only {
display: none;
}
.locales {
.locales, .loginstatus {
display: inline;
a {
text-decoration: none;
@@ -45,6 +45,9 @@ footer nav {
vertical-align: baseline;
}
}
.loginstatus a {
margin-left: 10px;
}
.huge {
font-size: 40px;
}
@@ -300,6 +303,29 @@ h2 .label {
}
}
.quotabox {
display: inline-block;
vertical-align: top;
width: 50px;
.progress {
height: 7px;
margin-bottom: 2px;
}
.numbers {
font-size: 10px;
color: $text-muted;
display: block;
text-align: center;
}
&.availability .progress-bar-success {
background: lighten($brand-success, 20%);
}
}
@for $i from 0 through 100 {
.progress-bar-#{$i} { width: 1% * $i; }
}
@import "_iframe.scss";
@import "_a11y.scss";
@import "_print.scss";

View File

@@ -115,6 +115,7 @@ def team(organizer):
can_change_vouchers=True,
can_view_vouchers=True,
can_change_orders=True,
can_manage_customers=True,
can_change_organizer_settings=True
)

View File

@@ -0,0 +1,130 @@
#
# 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/>.
#
import pytest
from django_scopes import scopes_disabled
@pytest.fixture
def customer(organizer, event):
return organizer.customers.create(
identifier="8WSAJCJ",
email="foo@example.org",
name_parts={"_legacy": "Foo"},
name_cached="Foo",
is_verified=False,
)
TEST_CUSTOMER_RES = {
"identifier": "8WSAJCJ",
"email": "foo@example.org",
"name": "Foo",
"name_parts": {
"_legacy": "Foo",
},
"is_active": True,
"is_verified": False,
"last_login": None,
"date_joined": "2021-04-06T13:44:22.809216Z",
"locale": "en",
"last_modified": "2021-04-06T13:44:22.809377Z"
}
@pytest.mark.django_db
def test_customer_list(token_client, organizer, customer):
res = dict(TEST_CUSTOMER_RES)
res["date_joined"] = customer.date_joined.isoformat().replace('+00:00', 'Z')
res["last_modified"] = customer.last_modified.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/customers/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_customer_detail(token_client, organizer, customer):
res = dict(TEST_CUSTOMER_RES)
res["date_joined"] = customer.date_joined.isoformat().replace('+00:00', 'Z')
res["last_modified"] = customer.last_modified.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_customer_create(token_client, organizer):
resp = token_client.post(
'/api/v1/organizers/{}/customers/'.format(organizer.slug),
format='json',
data={
'identifier': 'IGNORED',
'email': 'bar@example.com',
'name_parts': {
"_scheme": "given_family",
'given_name': 'John',
'family_name': 'Doe',
},
'is_active': True,
'is_verified': True,
}
)
assert resp.status_code == 201
with scopes_disabled():
customer = organizer.customers.get(identifier=resp.data['identifier'])
assert customer.identifier != 'IGNORED'
assert customer.email == 'bar@example.com'
assert customer.is_active
assert customer.name == 'John Doe'
assert customer.is_verified
@pytest.mark.django_db
def test_customer_patch(token_client, organizer, customer):
resp = token_client.patch(
'/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier),
format='json',
data={
'email': 'blubb@example.org',
}
)
assert resp.status_code == 200
customer.refresh_from_db()
assert customer.email == 'blubb@example.org'
@pytest.mark.django_db
def test_customer_anonymize(token_client, organizer, customer):
resp = token_client.post(
'/api/v1/organizers/{}/customers/{}/anonymize/'.format(organizer.slug, customer.identifier),
)
assert resp.status_code == 200
customer.refresh_from_db()
assert customer.email is None
@pytest.mark.django_db
def test_customer_delete(token_client, organizer, customer):
resp = token_client.delete(
'/api/v1/organizers/{}/customers/{}/'.format(organizer.slug, customer.identifier),
)
assert resp.status_code == 405

View File

@@ -283,7 +283,13 @@ TEST_ITEM_RES = {
"original_price": None,
"meta_data": {
"day": "Tuesday"
}
},
"require_membership": False,
"require_membership_types": [],
"grant_membership_type": None,
"grant_membership_duration_like_event": True,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
}

View File

@@ -0,0 +1,151 @@
#
# 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 datetime
import pytest
import pytz
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.models import Membership
@pytest.fixture
def membershiptype(organizer):
return organizer.membership_types.create(
name=LazyI18nString({"en": "Week pass"}),
transferable=True,
allow_parallel_usage=False,
max_usages=15,
)
@pytest.fixture
def customer(organizer):
return organizer.customers.create(
identifier="8WSAJCJ",
email="foo@example.org",
name_parts={"_legacy": "Foo"},
name_cached="Foo",
is_verified=False,
)
@pytest.fixture
def membership(organizer, customer, membershiptype):
return customer.memberships.create(
membership_type=membershiptype,
date_start=datetime(2021, 4, 1, 0, 0, 0, 0, tzinfo=pytz.UTC),
date_end=datetime(2021, 4, 8, 23, 59, 59, 999999, tzinfo=pytz.UTC),
attendee_name_parts={
"_scheme": "given_family",
'given_name': 'John',
'family_name': 'Doe',
}
)
TEST_MEMBERSHIP_RES = {
"customer": "8WSAJCJ",
"date_start": "2021-04-01T00:00:00Z",
"date_end": "2021-04-08T23:59:59.999999Z",
"attendee_name_parts": {
"_scheme": "given_family",
'given_name': 'John',
'family_name': 'Doe',
}
}
@pytest.mark.django_db
def test_membership_list(token_client, organizer, membershiptype, membership):
res = dict(TEST_MEMBERSHIP_RES)
res['membership_type'] = membershiptype.pk
res['id'] = membership.pk
resp = token_client.get('/api/v1/organizers/{}/memberships/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_membership_detail(token_client, organizer, membershiptype, membership):
res = dict(TEST_MEMBERSHIP_RES)
res['membership_type'] = membershiptype.pk
res['id'] = membership.pk
resp = token_client.get('/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membershiptype.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_membership_create(token_client, organizer, membershiptype, customer):
resp = token_client.post(
'/api/v1/organizers/{}/memberships/'.format(organizer.slug),
format='json',
data={
"customer": customer.identifier,
"membership_type": membershiptype.pk,
"date_start": "2021-04-01T00:00:00.000Z",
"date_end": "2021-04-08T23:59:59.999999Z",
}
)
assert resp.status_code == 201
with scopes_disabled():
membership = Membership.objects.get(id=resp.data['id'])
assert membership.customer == customer
assert membership.membership_type == membershiptype
@pytest.mark.django_db
def test_membership_patch(token_client, organizer, customer, membership):
resp = token_client.patch(
'/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk),
format='json',
data={
"date_end": "2021-04-03T23:59:59.999999Z",
}
)
assert resp.status_code == 200
membership.refresh_from_db()
assert membership.date_end.isoformat() == "2021-04-03T23:59:59.999999+00:00"
with scopes_disabled():
other_customer = organizer.customers.create()
resp = token_client.patch(
'/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk),
format='json',
data={
"customer": other_customer.identifier,
}
)
assert resp.status_code == 200
membership.refresh_from_db()
assert membership.customer == customer # change is ignored
@pytest.mark.django_db
def test_membership_delete(token_client, organizer, membership):
resp = token_client.delete(
'/api/v1/organizers/{}/memberships/{}/'.format(organizer.slug, membership.pk),
)
assert resp.status_code == 405

View File

@@ -0,0 +1,108 @@
#
# 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/>.
#
import pytest
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
@pytest.fixture
def membershiptype(organizer, event):
return organizer.membership_types.create(
name=LazyI18nString({"en": "Week pass"}),
transferable=True,
allow_parallel_usage=False,
max_usages=15,
)
TEST_TYPE_RES = {
"name": {
"en": "Week pass"
},
"transferable": True,
"allow_parallel_usage": False,
"max_usages": 15,
}
@pytest.mark.django_db
def test_membershiptype_list(token_client, organizer, membershiptype):
res = dict(TEST_TYPE_RES)
res["id"] = membershiptype.pk
resp = token_client.get('/api/v1/organizers/{}/membershiptypes/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_membershiptype_detail(token_client, organizer, membershiptype):
res = dict(TEST_TYPE_RES)
res["id"] = membershiptype.pk
resp = token_client.get('/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_membershiptype_create(token_client, organizer):
resp = token_client.post(
'/api/v1/organizers/{}/membershiptypes/'.format(organizer.slug),
format='json',
data={
"name": {
"en": "Week pass"
},
"transferable": True,
"allow_parallel_usage": False,
"max_usages": 15,
}
)
assert resp.status_code == 201
with scopes_disabled():
membershiptype = organizer.membership_types.get(id=resp.data['id'])
assert str(membershiptype.name) == "Week pass"
assert membershiptype.transferable
assert not membershiptype.allow_parallel_usage
@pytest.mark.django_db
def test_membershiptype_patch(token_client, organizer, membershiptype):
resp = token_client.patch(
'/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk),
format='json',
data={
'transferable': False,
}
)
assert resp.status_code == 200
membershiptype.refresh_from_db()
assert not membershiptype.transferable
@pytest.mark.django_db
def test_membershiptype_delete(token_client, organizer, membershiptype):
resp = token_client.delete(
'/api/v1/organizers/{}/membershiptypes/{}/'.format(organizer.slug, membershiptype.pk),
)
assert resp.status_code == 204
assert not organizer.membership_types.exists()

View File

@@ -236,6 +236,7 @@ TEST_ORDER_RES = {
"email": "dummy@dummy.test",
"phone": None,
"locale": "en",
"customer": None,
"datetime": "2017-12-01T10:00:00Z",
"expires": "2017-12-10T10:00:00Z",
"payment_date": "2017-12-01",
@@ -1633,6 +1634,9 @@ def test_order_create(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
with scopes_disabled():
customer = organizer.customers.create()
res['customer'] = customer.identifier
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
@@ -1641,6 +1645,7 @@ def test_order_create(token_client, organizer, event, item, quota, question):
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
assert o.customer == customer
assert o.email == "dummy@dummy.test"
assert o.phone == "+49622112345"
assert o.locale == "en"
@@ -1712,6 +1717,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'testmode': False,
'email': 'dummy@dummy.test',
'phone': '+49622112345',
'customer': None,
'locale': 'en',
'datetime': None,
'payment_date': None,

View File

@@ -179,6 +179,25 @@ org_permission_sub_urls = [
('put', 'can_change_organizer_settings', 'webhooks/1/', 404),
('patch', 'can_change_organizer_settings', 'webhooks/1/', 404),
('delete', 'can_change_organizer_settings', 'webhooks/1/', 404),
('get', 'can_manage_customers', 'customers/', 200),
('post', 'can_manage_customers', 'customers/', 201),
('get', 'can_manage_customers', 'customers/1/', 404),
('patch', 'can_manage_customers', 'customers/1/', 404),
('post', 'can_manage_customers', 'customers/1/anonymize/', 404),
('put', 'can_manage_customers', 'customers/1/', 404),
('delete', 'can_manage_customers', 'customers/1/', 404),
('get', 'can_manage_customers', 'memberships/', 200),
('post', 'can_manage_customers', 'memberships/', 400),
('get', 'can_manage_customers', 'memberships/1/', 404),
('patch', 'can_manage_customers', 'memberships/1/', 404),
('put', 'can_manage_customers', 'memberships/1/', 404),
('delete', 'can_manage_customers', 'memberships/1/', 404),
('get', 'can_change_organizer_settings', 'membershiptypes/', 200),
('post', 'can_change_organizer_settings', 'membershiptypes/', 400),
('get', 'can_change_organizer_settings', 'membershiptypes/1/', 404),
('patch', 'can_change_organizer_settings', 'membershiptypes/1/', 404),
('put', 'can_change_organizer_settings', 'membershiptypes/1/', 404),
('delete', 'can_change_organizer_settings', 'membershiptypes/1/', 404),
('get', 'can_manage_gift_cards', 'giftcards/', 200),
('post', 'can_manage_gift_cards', 'giftcards/', 400),
('get', 'can_manage_gift_cards', 'giftcards/1/', 404),

View File

@@ -39,6 +39,7 @@ def second_team(organizer, event):
TEST_TEAM_RES = {
'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True,
'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True,
'can_manage_customers': True,
'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True,
'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False
}
@@ -46,6 +47,7 @@ TEST_TEAM_RES = {
SECOND_TEAM_RES = {
'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'],
'can_create_events': False,
'can_manage_customers': False,
'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False,
'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False,
'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False

Some files were not shown because too many files have changed in this diff Show More