Reusable media (#3131)

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2023-04-03 10:45:22 +02:00
committed by GitHub
parent 377117548d
commit d0b449ea89
67 changed files with 2876 additions and 133 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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