forked from CGM_Public/pretix_original
@@ -1419,6 +1419,62 @@ class CustomerFilterForm(FilterForm):
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class ReusableMediaFilterForm(FilterForm):
|
||||
orders = {
|
||||
'type': 'type',
|
||||
'identifier': 'identifier',
|
||||
}
|
||||
query = forms.CharField(
|
||||
label=_('Search query'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Search query'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
required=False,
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('active', _('active')),
|
||||
('disabled', _('disabled')),
|
||||
('expired', _('expired')),
|
||||
)
|
||||
)
|
||||
|
||||
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(identifier__icontains=query)
|
||||
| Q(customer__identifier__icontains=query)
|
||||
| Q(customer__external_identifier__istartswith=query)
|
||||
| Q(linked_orderposition__order__code__icontains=query)
|
||||
| Q(linked_giftcard__secret__icontains=query)
|
||||
)
|
||||
|
||||
if fdata.get('status') == 'active':
|
||||
qs = qs.filter(Q(expires__gt=now()) | Q(expires__isnull=False), active=True)
|
||||
elif fdata.get('status') == 'disabled':
|
||||
qs = qs.filter(active=False)
|
||||
elif fdata.get('status') == 'expired':
|
||||
qs = qs.filter(expires__lte=now())
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
qs = qs.order_by("identifier", "type", "organizer")
|
||||
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class TeamFilterForm(FilterForm):
|
||||
orders = {
|
||||
'name': 'name',
|
||||
|
||||
@@ -401,6 +401,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
'validity_dynamic_duration_months',
|
||||
'validity_dynamic_start_choice',
|
||||
'validity_dynamic_start_choice_day_limit',
|
||||
'media_type',
|
||||
'media_policy',
|
||||
)
|
||||
for f in fields:
|
||||
setattr(self.instance, f, getattr(src, f))
|
||||
@@ -592,6 +594,10 @@ class ItemUpdateForm(I18nModelForm):
|
||||
del self.fields['grant_membership_duration_days']
|
||||
del self.fields['grant_membership_duration_months']
|
||||
|
||||
if not self.event.settings.reusable_media_active:
|
||||
del self.fields['media_type']
|
||||
del self.fields['media_policy']
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d['issue_giftcard']:
|
||||
@@ -635,6 +641,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
_("The start of validity must be before the end of validity.")
|
||||
)
|
||||
|
||||
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
|
||||
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
@@ -693,6 +701,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'validity_dynamic_duration_months',
|
||||
'validity_dynamic_start_choice',
|
||||
'validity_dynamic_start_choice_day_limit',
|
||||
'media_policy',
|
||||
'media_type',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
|
||||
@@ -41,10 +41,14 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.forms import inlineformset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
from i18nfield.forms import (
|
||||
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
||||
)
|
||||
@@ -62,15 +66,18 @@ from pretix.base.forms.questions import (
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||
MembershipType, Organizer, Team,
|
||||
MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
|
||||
)
|
||||
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
|
||||
from pretix.base.models.organizer import OrganizerFooterLink
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings,
|
||||
)
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms.event import (
|
||||
SafeEventMultipleChoiceField, multimail_validate,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -208,6 +215,7 @@ class TeamForm(forms.ModelForm):
|
||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||
'can_change_teams', 'can_change_organizer_settings',
|
||||
'can_manage_gift_cards', 'can_manage_customers',
|
||||
'can_manage_reusable_media',
|
||||
'can_change_event_settings', 'can_change_items',
|
||||
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
|
||||
'can_view_vouchers', 'can_change_vouchers']
|
||||
@@ -389,6 +397,12 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'cookie_consent_dialog_text_secondary',
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'reusable_media_active',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
]
|
||||
|
||||
organizer_logo_image = ExtFileField(
|
||||
@@ -431,6 +445,26 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
))
|
||||
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||
]
|
||||
self.fields['reusable_media_active'].label = mark_safe(
|
||||
conditional_escape(self.fields['reusable_media_active'].label) +
|
||||
' ' +
|
||||
'<span class="label label-info">{}</span>'.format(_('experimental'))
|
||||
)
|
||||
self.fields['reusable_media_active'].help_text = mark_safe(
|
||||
conditional_escape(self.fields['reusable_media_active'].help_text) +
|
||||
' ' +
|
||||
'<br/><span class="fa fa-flask"></span> ' +
|
||||
_('This feature is currently in an experimental stage. It only supports very limited use cases and might '
|
||||
'change at any point.')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
validate_organizer_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
@@ -626,6 +660,116 @@ class GiftCardUpdateForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class SafeOrderPositionChoiceField(forms.ModelChoiceField):
|
||||
def __init__(self, queryset, **kwargs):
|
||||
queryset = queryset.model.all.none()
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
def label_from_instance(self, op):
|
||||
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
|
||||
|
||||
|
||||
class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate': _("An medium with this type and identifier is already registered."),
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
organizer = self.instance.organizer
|
||||
|
||||
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
|
||||
self.fields['linked_orderposition'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Ticket')
|
||||
}
|
||||
)
|
||||
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
|
||||
self.fields['linked_orderposition'].required = False
|
||||
|
||||
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
|
||||
self.fields['linked_giftcard'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Gift card')
|
||||
}
|
||||
)
|
||||
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
|
||||
self.fields['linked_giftcard'].required = False
|
||||
|
||||
if organizer.settings.customer_accounts:
|
||||
self.fields['customer'].queryset = organizer.customers.all()
|
||||
self.fields['customer'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Customer')
|
||||
}
|
||||
)
|
||||
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||
self.fields['customer'].required = False
|
||||
else:
|
||||
del self.fields['customer']
|
||||
|
||||
def clean(self):
|
||||
identifier = self.cleaned_data.get('identifier')
|
||||
type = self.cleaned_data.get('type')
|
||||
|
||||
if identifier is not None and type is not None:
|
||||
try:
|
||||
self.instance.organizer.reusable_media.exclude(pk=self.instance.pk).get(
|
||||
identifier=identifier,
|
||||
type=type,
|
||||
)
|
||||
except ReusableMedium.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate'],
|
||||
code='duplicate',
|
||||
)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class ReusableMediumCreateForm(ReusableMediumUpdateForm):
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
}
|
||||
|
||||
|
||||
class CustomerUpdateForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate': _("An account with this email address is already registered."),
|
||||
|
||||
@@ -362,6 +362,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'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.reusable_medium.created': _('The reusable medium has been created.'),
|
||||
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
|
||||
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
|
||||
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
|
||||
'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.'),
|
||||
|
||||
@@ -578,6 +578,16 @@ def get_organizer_navigation(request):
|
||||
'children': children,
|
||||
})
|
||||
|
||||
if request.organizer.settings.reusable_media_active:
|
||||
nav.append({
|
||||
'label': _('Reusable media'),
|
||||
'url': reverse('control:organizer.reusable_media', kwargs={
|
||||
'organizer': request.organizer.slug
|
||||
}),
|
||||
'icon': 'key',
|
||||
'active': 'organizer.reusable_medi' in url.url_name,
|
||||
})
|
||||
|
||||
if 'can_change_organizer_settings' in request.orgapermset:
|
||||
nav.append({
|
||||
'label': _('Devices'),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% load formset_tags %}
|
||||
{% block inside %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-lg-10">
|
||||
@@ -178,6 +179,12 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Tickets & Badges" %}</legend>
|
||||
{% bootstrap_field form.generate_tickets layout="control" %}
|
||||
{% if form.media_policy %}
|
||||
{% bootstrap_field form.media_policy layout="control" %}
|
||||
{% endif %}
|
||||
{% if form.media_type %}
|
||||
{% bootstrap_field form.media_type layout="control" %}
|
||||
{% endif %}
|
||||
{% for f in plugin_forms %}
|
||||
{% if f.is_layouts %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
|
||||
@@ -462,6 +462,14 @@
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for m in line.linked_media.all %}
|
||||
<div class="cart-icon-details">
|
||||
<dd>
|
||||
<span class="fa fa-key fa-fw" aria-hidden="true"></span>
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">{{ m.identifier }}</a> <span class="text-muted">({{ m.get_type_display }})</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not line.canceled %}
|
||||
<div class="position-buttons">
|
||||
{% if line.generate_ticket %}
|
||||
|
||||
@@ -200,6 +200,70 @@
|
||||
{% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %}
|
||||
{% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Reusable media" %}</legend>
|
||||
{% bootstrap_field sform.reusable_media_active layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}">
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{% trans "Barcode media" %}</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A "barcode medium" can be any printed or digital representation of a barcode.
|
||||
The medium will initially be created through the sale of a product that has a
|
||||
media policy requiring such a medium as well as a ticket or badge layout that
|
||||
includes the "Reusable Medium ID" as a QR code. Later, the same barcode may
|
||||
be re-used during the sale of a different product.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
Barcode media can currently only be connected to tickets.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
This subsequent reuse of the barcode is currently only supported during POS sales.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field sform.reusable_media_type_barcode layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_barcode.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_barcode_identifier_length layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{% trans "NFC UID-based" %}</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
This medium type can work with almost any type of NFC chip. With this
|
||||
option, only the UID of the NFC chip is used for identification.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
NFC media can currently only be connected to gift cards.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="help-block">
|
||||
<span class="fa fa-warning text-warning"></span>
|
||||
{% blocktrans trimmed %}
|
||||
This method does not provide a high level of protection against abuse since it
|
||||
is possible for malicious users to clone someone's chip with the same UID.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid_autocreate_giftcard.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard_currency layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoices" %}</legend>
|
||||
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load urlreplace %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Reusable media" %}{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% trans "Reusable media" %}
|
||||
</h1>
|
||||
{% if media|length == 0 and not filter_form.filtered %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No media have been created yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Filter" %}</h3>
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p>
|
||||
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Identifier" context "reusable_media" %}
|
||||
<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 "Media type" context "reusable_media" %}
|
||||
<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 "Connections" context "reusable_media" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in media %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">
|
||||
{% if not m.active %}<strike>{% endif %}
|
||||
<strong>{{ m.identifier }}</strong>
|
||||
{% if not m.active %}</strike>{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ m.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.customer %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
|
||||
{{ m.customer }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if m.linked_orderposition %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-ticket fa-fw"></span>
|
||||
<a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}">
|
||||
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if m.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=m.linked_giftcard.id %}">
|
||||
{{ m.linked_giftcard.secret }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}"
|
||||
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 %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ id }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ 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">
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Media type" context "reusable_media" %}</dt>
|
||||
<dd>{{ medium.get_type_display }}</dd>
|
||||
<dt>{% trans "Identifier" context "reusable_media" %}</dt>
|
||||
<dd><code>{{ medium.identifier }}</code></dd>
|
||||
<dt>{% trans "Status" %}</dt>
|
||||
<dd>
|
||||
{% if not medium.active %}
|
||||
{% trans "disabled" %}
|
||||
{% elif medium.is_expired %}
|
||||
{% trans "expired" %}
|
||||
{% else %}
|
||||
{% trans "active" %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>{% trans "Connections" context "reusable_media" %}</dt>
|
||||
<dd>
|
||||
{% if medium.customer %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
|
||||
{{ medium.customer }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if medium.linked_orderposition %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-ticket fa-fw"></span>
|
||||
<a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}">
|
||||
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if medium.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
|
||||
{{ medium.linked_giftcard.secret }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if medium.notes %}
|
||||
<dt>{% trans "Notes" %}</dt>
|
||||
<dd>{{ medium.notes }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</form>
|
||||
<div class="text-right">
|
||||
<a href="{% url "control:organizer.reusable_medium.edit" organizer=request.organizer.slug pk=medium.pk %}"
|
||||
class="btn btn-default">
|
||||
<i class="fa fa-edit"></i> {% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Medium history" context "reusable_media" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=medium %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% if not medium.pk %}
|
||||
{% trans "New medium" context "reusable_media" %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ id }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% if not medium.pk %}
|
||||
{% trans "New medium" context "reusable_media" %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ id }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
</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 %}
|
||||
@@ -24,6 +24,7 @@
|
||||
{% 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_manage_reusable_media layout="control" %}
|
||||
{% bootstrap_field form.can_change_teams layout="control" %}
|
||||
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
|
||||
</fieldset>
|
||||
|
||||
@@ -164,7 +164,15 @@ urlpatterns = [
|
||||
organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
|
||||
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media$', organizer.ReusableMediaListView.as_view(), name='organizer.reusable_media'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/add$',
|
||||
organizer.ReusableMediumCreateView.as_view(), name='organizer.reusable_medium.create'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/$',
|
||||
organizer.ReusableMediumDetailView.as_view(), name='organizer.reusable_medium'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/edit$',
|
||||
organizer.ReusableMediumUpdateView.as_view(), name='organizer.reusable_medium.edit'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards/select2$', typeahead.giftcard_select2, name='organizer.giftcards.select2'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/edit$', organizer.GiftCardUpdateView.as_view(),
|
||||
@@ -213,6 +221,7 @@ urlpatterns = [
|
||||
name='organizer.export.scheduled.run'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'),
|
||||
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
|
||||
re_path(r'^events/$', main.EventList.as_view(), name='events'),
|
||||
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
|
||||
|
||||
@@ -367,7 +367,7 @@ class OrderDetail(OrderView):
|
||||
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
|
||||
'discount',
|
||||
).prefetch_related(
|
||||
'item__questions', 'issued_gift_cards',
|
||||
'item__questions', 'issued_gift_cards', 'linked_media',
|
||||
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
|
||||
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
|
||||
).order_by('positionid')
|
||||
|
||||
@@ -73,7 +73,7 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
|
||||
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
|
||||
ScheduledOrganizerExport, Team, TeamInvite, User,
|
||||
ReusableMedium, ScheduledOrganizerExport, Team, TeamInvite, User,
|
||||
)
|
||||
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
|
||||
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
|
||||
@@ -92,7 +92,7 @@ from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.control.forms.exports import ScheduledOrganizerExportForm
|
||||
from pretix.control.forms.filter import (
|
||||
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
|
||||
OrganizerFilterForm, TeamFilterForm,
|
||||
OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm,
|
||||
)
|
||||
from pretix.control.forms.orders import ExporterForm
|
||||
from pretix.control.forms.organizer import (
|
||||
@@ -100,8 +100,9 @@ from pretix.control.forms.organizer import (
|
||||
EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
|
||||
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
|
||||
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
|
||||
OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm,
|
||||
TeamForm, WebHookForm,
|
||||
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
|
||||
ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
|
||||
WebHookForm,
|
||||
)
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.control.logdisplay import OVERVIEW_BANLIST
|
||||
@@ -533,7 +534,7 @@ class OrganizerCreate(CreateView):
|
||||
organizer=form.instance, name=_('Administrators'),
|
||||
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
|
||||
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
|
||||
can_manage_customers=True,
|
||||
can_manage_customers=True, can_manage_reusable_media=True,
|
||||
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
|
||||
)
|
||||
t.members.add(self.request.user)
|
||||
@@ -2738,3 +2739,101 @@ class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequire
|
||||
'organizer': self.request.organizer.slug,
|
||||
'customer': self.object.identifier,
|
||||
})
|
||||
|
||||
|
||||
class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
model = ReusableMedium
|
||||
template_name = 'pretixcontrol/organizers/reusable_media.html'
|
||||
permission = 'can_manage_reusable_media'
|
||||
context_object_name = 'media'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.organizer.reusable_media.select_related(
|
||||
'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event',
|
||||
'linked_giftcard'
|
||||
)
|
||||
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 ReusableMediaFilterForm(data=self.request.GET, request=self.request)
|
||||
|
||||
|
||||
class ReusableMediumDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/organizers/reusable_medium.html'
|
||||
permission = 'can_manage_reusable_media'
|
||||
|
||||
@cached_property
|
||||
def medium(self):
|
||||
return get_object_or_404(
|
||||
self.request.organizer.reusable_media,
|
||||
pk=self.kwargs.get('pk')
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['medium'] = self.medium
|
||||
return ctx
|
||||
|
||||
|
||||
class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
|
||||
permission = 'can_manage_reusable_media'
|
||||
context_object_name = 'medium'
|
||||
form_class = ReusableMediumCreateForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
ctx = super().get_form_kwargs()
|
||||
c = ReusableMedium(organizer=self.request.organizer)
|
||||
ctx['instance'] = c
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
r = super().form_valid(form)
|
||||
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={
|
||||
k: getattr(form.instance, k)
|
||||
for k in form.changed_data
|
||||
})
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return r
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.reusable_medium', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'pk': self.object.pk,
|
||||
})
|
||||
|
||||
|
||||
class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
|
||||
permission = 'can_manage_reusable_media'
|
||||
context_object_name = 'medium'
|
||||
form_class = ReusableMediumUpdateForm
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(
|
||||
self.request.organizer.reusable_media,
|
||||
pk=self.kwargs.get('pk')
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.has_changed():
|
||||
self.object.log_action('pretix.reusable_medium.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.reusable_medium', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
'pk': self.object.pk,
|
||||
})
|
||||
|
||||
@@ -48,8 +48,8 @@ from django.utils.translation import gettext as _, pgettext
|
||||
|
||||
from pretix.base.models import (
|
||||
EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue,
|
||||
ItemVariation, ItemVariationMetaValue, Order, Organizer, SubEventMetaValue,
|
||||
User, Voucher,
|
||||
ItemVariation, ItemVariationMetaValue, Order, OrderPosition, Organizer,
|
||||
SubEventMetaValue, User, Voucher,
|
||||
)
|
||||
from pretix.control.forms.event import EventWizardCopyForm
|
||||
from pretix.control.permissions import (
|
||||
@@ -172,6 +172,104 @@ def event_list(request):
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
@organizer_permission_required(("can_manage_gift_cards", "can_manage_reusable_media"))
|
||||
def giftcard_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
if request.user.has_organizer_permission(request.organizer, 'can_manage_gift_cards', request):
|
||||
qs = request.organizer.issued_gift_cards.filter(
|
||||
Q(secret__icontains=query)
|
||||
).order_by('secret')
|
||||
else:
|
||||
qs = request.organizer.issued_gift_cards.filter(
|
||||
Q(secret__iexact=query)
|
||||
).order_by('secret')
|
||||
|
||||
if not query:
|
||||
qs = qs.none()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@organizer_permission_required(("can_manage_reusable_media"))
|
||||
def ticket_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
try:
|
||||
page = int(request.GET.get('page', '1'))
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
qs_orders = OrderPosition.all.select_related('order', 'order__event', 'item', 'variation').filter(
|
||||
order__event__organizer=request.organizer,
|
||||
).order_by()
|
||||
|
||||
exact_match = Q(secret__iexact=query)
|
||||
soft_match = Q(secret__icontains=query)
|
||||
|
||||
qsplit = query.split("-")
|
||||
|
||||
if len(qsplit) >= 3 and qsplit[2].isdigit():
|
||||
soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__iexact=qsplit[1], positionid=qsplit[2])
|
||||
elif len(qsplit) >= 2 and qsplit[1].isdigit():
|
||||
soft_match |= Q(order__code__istartswith=qsplit[0], positionid=qsplit[1])
|
||||
elif len(qsplit) >= 2:
|
||||
soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__istartswith=qsplit[1])
|
||||
else:
|
||||
soft_match |= Q(order__code__istartswith=qsplit[0])
|
||||
|
||||
if not request.user.has_active_staff_session(request.session.session_key):
|
||||
qs_orders = qs_orders.filter(
|
||||
exact_match | (
|
||||
soft_match & (
|
||||
Q(order__event__organizer_id__in=request.user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True))
|
||||
| Q(order__event_id__in=request.user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True))
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
qs_orders = qs_orders.filter(exact_match | soft_match)
|
||||
|
||||
if not query:
|
||||
qs_orders = qs_orders.none()
|
||||
|
||||
total = qs_orders.count()
|
||||
pagesize = 20
|
||||
offset = (page - 1) * pagesize
|
||||
doc = {
|
||||
'results': [
|
||||
{
|
||||
'id': op.pk,
|
||||
'text': f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})',
|
||||
'event': str(op.order.event)
|
||||
}
|
||||
for op in qs_orders[offset:offset + pagesize]
|
||||
],
|
||||
'pagination': {
|
||||
"more": total >= (offset + pagesize)
|
||||
}
|
||||
}
|
||||
return JsonResponse(doc)
|
||||
|
||||
|
||||
@organizer_permission_required("can_manage_customers")
|
||||
def customer_select2(request, **kwargs):
|
||||
query = request.GET.get('query', '')
|
||||
|
||||
Reference in New Issue
Block a user