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

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