Gift cards: Improved support for cross-organizer acceptance (#3311)

Co-authored-by: Martin Gross <martin@pc-coholic.de>
This commit is contained in:
Raphael Michel
2023-06-15 14:17:40 +02:00
committed by GitHub
parent b3c917925c
commit f8be8296dd
22 changed files with 605 additions and 139 deletions

View File

@@ -60,6 +60,8 @@ class NestedGiftCardSerializer(GiftCardSerializer):
class ReusableMediaSerializer(I18nAwareModelSerializer):
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -111,6 +113,7 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
model = ReusableMedium
fields = (
'id',
'organizer',
'created',
'updated',
'type',

View File

@@ -36,9 +36,9 @@ from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.auth import get_auth_backends
from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, OrderPosition, Organizer, ReusableMedium, SeatingPlan,
Team, TeamAPIToken, TeamInvite, User,
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
@@ -183,8 +183,11 @@ class GiftCardSerializer(I18nAwareModelSerializer):
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(
issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
Q(issuer=self.context["organizer"]) |
Q(issuer__in=GiftCardAcceptance.objects.filter(
acceptor=self.context["organizer"],
active=True,
).values_list('issuer', flat=True))
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)

View File

@@ -39,7 +39,8 @@ from pretix.api.serializers.media import (
)
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition,
ReusableMedium,
)
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
@@ -135,12 +136,28 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
s = self.get_serializer(m)
return Response({"result": s.data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
if m:
s = self.get_serializer(m)
return Response({"result": s.data})
try:
with scopes_disabled():
m = ReusableMedium.objects.get(
organizer__in=GiftCardAcceptance.objects.filter(
acceptor=request.organizer,
active=True,
reusable_media=True,
).values_list('issuer', flat=True),
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
)
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m)
return Response({"result": s.data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
if m:
s = self.get_serializer(m)
return Response({"result": s.data})
return Response({"result": None})

View File

@@ -1147,7 +1147,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
def iterate_list(self, form_data):
qs = GiftCardTransaction.objects.filter(
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
).order_by('datetime').select_related('card', 'order', 'order__event', 'acceptor')
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
@@ -1163,6 +1163,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
_('Amount'),
_('Currency'),
_('Order'),
_('Organizer'),
]
yield headers
@@ -1174,6 +1175,7 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
obj.value,
obj.card.currency,
obj.order.full_code if obj.order else None,
str(obj.acceptor or ""),
]
yield row

View File

@@ -0,0 +1,38 @@
# Generated by Django 3.2.18 on 2023-05-12 10:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0241_itemmetaproperties_required_values'),
]
operations = [
migrations.RenameField(
model_name='giftcardacceptance',
old_name='collector',
new_name='acceptor',
),
migrations.AddField(
model_name='giftcardacceptance',
name='active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='giftcardacceptance',
name='reusable_media',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='giftcardacceptance',
name='issuer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_acceptor_acceptance', to='pretixbase.organizer'),
),
migrations.AlterUniqueTogether(
name='giftcardacceptance',
unique_together={('issuer', 'acceptor')},
),
]

View File

@@ -46,14 +46,19 @@ def gen_giftcard_secret(length=8):
class GiftCardAcceptance(models.Model):
issuer = models.ForeignKey(
'Organizer',
related_name='gift_card_collector_acceptance',
related_name='gift_card_acceptor_acceptance',
on_delete=models.CASCADE
)
collector = models.ForeignKey(
acceptor = models.ForeignKey(
'Organizer',
related_name='gift_card_issuer_acceptance',
on_delete=models.CASCADE
)
active = models.BooleanField(default=True)
reusable_media = models.BooleanField(default=False)
class Meta:
unique_together = (('issuer', 'acceptor'),)
class GiftCard(LoggedModel):
@@ -114,7 +119,7 @@ class GiftCard(LoggedModel):
return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00')
def accepted_by(self, organizer):
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists()
return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, acceptor=organizer, active=True).exists()
def save(self, *args, **kwargs):
if not self.secret:

View File

@@ -40,7 +40,7 @@ from django.conf import settings
from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.db.models import Q
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -157,17 +157,19 @@ class Organizer(LoggedModel):
return self.cache.get_or_set(
key='has_gift_cards',
timeout=15,
default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.exists()
default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.filter(active=True).exists()
)
@property
def accepted_gift_cards(self):
from .giftcards import GiftCard, GiftCardAcceptance
return GiftCard.objects.annotate(
accepted=Exists(GiftCardAcceptance.objects.filter(issuer=OuterRef('issuer'), collector=self))
).filter(
Q(issuer=self) | Q(accepted=True)
return GiftCard.objects.filter(
Q(issuer=self) |
Q(issuer__in=GiftCardAcceptance.objects.filter(
acceptor=self,
active=True,
).values_list('issuer', flat=True))
)
@property

View File

@@ -65,8 +65,8 @@ 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, OrderPosition, Organizer, ReusableMedium, Team,
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
@@ -637,7 +637,11 @@ class GiftCardCreateForm(forms.ModelForm):
if GiftCard.objects.filter(
secret__iexact=s
).filter(
Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer)
Q(issuer=self.organizer) |
Q(issuer__in=GiftCardAcceptance.objects.filter(
acceptor=self.organizer,
active=True,
).values_list('issuer', flat=True))
).exists():
raise ValidationError(
_('A gift card with the same secret already exists in your or an affiliated organizer account.')
@@ -1026,3 +1030,32 @@ class SSOClientForm(I18nModelForm):
else:
del self.fields['client_id']
del self.fields['regenerate_client_secret']
class GiftCardAcceptanceInviteForm(forms.Form):
acceptor = forms.CharField(
label=_("Organizer short name"),
required=True,
)
reusable_media = forms.BooleanField(
label=_("Allow access to reusable media"),
help_text=_("This is required if you want the other organizer to participate in a shared system with e.g. "
"NFC payment chips. You should only use this option for organizers you trust, since (depending "
"on the activated medium types) this will grant the other organizer access to cryptographic key "
"material required to interact with the media type."),
required=False,
)
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
def clean_acceptor(self):
val = self.cleaned_data['acceptor']
try:
acceptor = Organizer.objects.exclude(pk=self.organizer.pk).get(slug=val)
except Organizer.DoesNotExist:
raise ValidationError(_('The selected organizer does not exist or cannot be invited.'))
if self.organizer.gift_card_acceptor_acceptance.filter(acceptor=acceptor).exists():
raise ValidationError(_('The selected organizer has already been invited.'))
return acceptor

View File

@@ -340,6 +340,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
'pretix.giftcards.acceptance.issuer.removed': _('A gift card issuer has been removed or declined.'),
'pretix.giftcards.acceptance.issuer.accepted': _('A new gift card issuer has been accepted.'),
'pretix.webhook.created': _('The webhook has been created.'),
'pretix.webhook.changed': _('The webhook has been changed.'),
'pretix.webhook.retries.expedited': _('The webhook call retry jobs have been manually expedited.'),

View File

@@ -519,13 +519,32 @@ def get_organizer_navigation(request):
})
if 'can_manage_gift_cards' in request.orgapermset:
children = []
children.append({
'label': _('Gift cards'),
'url': reverse('control:organizer.giftcards', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.giftcard' in url.url_name and 'acceptance' not in url.url_name,
'children': children,
})
if 'can_change_organizer_settings' in request.orgapermset:
children.append(
{
'label': _('Acceptance'),
'url': reverse('control:organizer.giftcards.acceptance', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.giftcards.acceptance' in url.url_name,
}
)
nav.append({
'label': _('Gift cards'),
'url': reverse('control:organizer.giftcards', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.giftcard' in url.url_name,
'icon': 'credit-card',
'children': children,
})
if request.organizer.settings.customer_accounts:

View File

@@ -6,6 +6,12 @@
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">
{{ gc.secret }}
</a>
{% if gc.issuer != request.organizer %}
<span class="text-muted">
<br>
<span class="fa fa-group"></span> {{ gc.issuer }}
</span>
{% endif %}
</dd>
<dt>{% trans "Issuer" %}</dt>
<dd>{{ gc.issuer }}</dd>

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load money %}
{% block inner %}
<h1>
{% trans "Invite organizer" %}
</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,150 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load money %}
{% block inner %}
<h1>
{% trans "Gift cards acceptance" %}
</h1>
<p>
{% blocktrans trimmed %}
This feature allows you to configure acceptance of gift cards across multiple organizer accounts.
{% endblocktrans %}
</p>
<form method="post">
{% csrf_token %}
<h2>
{% trans "Other organizers you accept gift cards from" %}
</h2>
{% if issuer_acceptance|length == 0 and not filter_form.filtered %}
<p>
{% blocktrans trimmed %}
You are not accepting gift cards from other organizers yet. If you want to do so, the other
organizer can add you to their list and afterwards, you can confirm this here.
{% endblocktrans %}
</p>
{% else %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Organizer" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Reusable media" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for gca in issuer_acceptance %}
<tr>
<td>
{{ gca.issuer.name }}<br><code>{{ gca.issuer.slug }}</code>
</td>
<td>
{% if gca.active %}
{% trans "active" %}
{% else %}
{% trans "invited" %}
{% endif %}
</td>
<td>
{% if gca.reusable_media %}
{% trans "active" %}
{% else %}
{% trans "disabled" %}
{% endif %}
</td>
<td class="text-right">
{% if gca.active %}
<button class="btn btn-danger" name="delete_issuer" value="{{ gca.issuer.slug }}">
{% trans "Remove" %}
</button>
{% else %}
<button class="btn btn-success" name="accept_issuer" value="{{ gca.issuer.slug }}">
{% trans "Accept" %}
</button>
<button class="btn btn-danger" name="delete_issuer" value="{{ gca.issuer.slug }}">
{% trans "Decline" %}
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</form>
<form method="post">
{% csrf_token %}
<h2>
{% trans "Other organizers accepting gift cards from you" %}
</h2>
<p>
{% blocktrans trimmed %}
You can invite other organizers to accept your gift cards. After you have done so, they need to go
to the same page in their account and accept your invitation. Note that other organizers will be able
to add money to gift cards as well that you will need to collect form them. It is your responsibility
to handle the exchange of money to offset the transactions between the two organizers.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
You can optionally control whether they can access your reusable media. This is required if you want
them to participate in a shared system with e.g. NFC payment chips.
{% endblocktrans %}
{% blocktrans trimmed %}
You should only use this option for organizers you trust, since (depending on the activated medium types)
this will grant the other organizer access to cryptographic key material required to interact with
the media type.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.giftcards.acceptance.invite" organizer=request.organizer.slug %}" class="btn btn-default">
{% trans "Invite new organizer" %}
</a>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Organizer" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Reusable media" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for gca in acceptor_acceptance %}
<tr>
<td>
{{ gca.acceptor.name }}<br><code>{{ gca.acceptor.slug }}</code>
</td>
<td>
{% if gca.active %}
{% trans "active" %}
{% else %}
{% trans "invited" %}
{% endif %}
</td>
<td>
{% if gca.reusable_media %}
{% trans "active" %}
{% else %}
{% trans "disabled" %}
{% endif %}
</td>
<td class="text-right">
<button class="btn btn-danger" name="delete_acceptor" value="{{ gca.acceptor.slug }}">
{% trans "Remove" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -99,44 +99,4 @@
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% if not is_paginated or page_obj.number == 1 %}
<form action="" method="post" class="form-inline">
{% csrf_token %}
<fieldset>
<legend>{% trans "Accepted gift cards of other organizers" %}</legend>
<p>
{% blocktrans trimmed %}
If you have access to multiple organizer accounts, you can configure that ticket shops in
this account will also accept gift codes issued through a different organizer account, and
vice versa.
{% endblocktrans %}
</p>
<ul>
{% for gca in request.organizer.gift_card_issuer_acceptance.all %}
<li>
<strong>{{ gca.issuer }}</strong>
<button type="submit" name="del" value="{{ gca.issuer.slug }}" class="btn btn-xs btn-danger">
<span class="fa fa-trash"></span>
</button>
</li>
{% empty %}
<li>
<em>{% trans "You are currently not accepting gift cards from other organizers." %}</em>
</li>
{% endfor %}
{% if other_organizers %}
<li>
<select name="add" class="form-control input-sm">
<option></option>
{% for o in other_organizers %}
<option value="{{ o.slug }}">{{ o }}</option>
{% endfor %}
</select>
<button class="btn btn-primary btn-sm" type="submit"><span class="fa fa-plus"></span></button>
</li>
{% endif %}
</ul>
</fieldset>
</form>
{% endif %}
{% endblock %}

View File

@@ -176,6 +176,10 @@ urlpatterns = [
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(),
name='organizer.giftcard.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards/acceptance$', organizer.GiftCardAcceptanceListView.as_view(),
name='organizer.giftcards.acceptance'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards/acceptance/invite$', organizer.GiftCardAcceptanceInviteView.as_view(),
name='organizer.giftcards.acceptance.invite'),
re_path(r'^organizer/(?P<organizer>[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
re_path(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
name='organizer.webhook.add'),

View File

@@ -77,7 +77,7 @@ from pretix.base.models import (
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
from pretix.base.models.giftcards import (
GiftCardTransaction, gen_giftcard_secret,
GiftCardAcceptance, GiftCardTransaction, gen_giftcard_secret,
)
from pretix.base.models.orders import CancellationRequest
from pretix.base.models.organizer import TeamAPIToken
@@ -96,12 +96,12 @@ from pretix.control.forms.filter import (
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
WebHookForm,
EventMetaPropertyForm, GateForm, GiftCardAcceptanceInviteForm,
GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm,
MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm,
OrganizerFooterLinkFormset, OrganizerForm, OrganizerSettingsForm,
OrganizerUpdateForm, ReusableMediumCreateForm, ReusableMediumUpdateForm,
SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -181,7 +181,8 @@ class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
return self.request.organizer
def get_queryset(self):
qs = self.request.user.get_events_with_any_permission(self.request).select_related('organizer').prefetch_related(
qs = self.request.user.get_events_with_any_permission(self.request).select_related(
'organizer').prefetch_related(
'organizer', '_settings_objects', 'organizer___settings_objects',
'organizer__meta_properties',
Prefetch(
@@ -211,7 +212,8 @@ class OrganizerDetail(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
ctx['meta_fields'] = [
self.filter_form['meta_{}'.format(p.name)] for p in self.organizer.meta_properties.filter(filter_allowed=True)
self.filter_form['meta_{}'.format(p.name)] for p in
self.organizer.meta_properties.filter(filter_allowed=True)
]
return ctx
@@ -316,7 +318,8 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
# 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():
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:
@@ -341,7 +344,8 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
if idx in self.supported_locale:
with language(self.supported_locale[idx], self.request.organizer.settings.region):
if k.startswith('mail_subject_'):
msgs[self.supported_locale[idx]] = format_map(bleach.clean(v), self.placeholders(preview_item))
msgs[self.supported_locale[idx]] = format_map(bleach.clean(v),
self.placeholders(preview_item))
else:
msgs[self.supported_locale[idx]] = markdown_compile_email(
format_map(v, self.placeholders(preview_item))
@@ -395,7 +399,8 @@ class OrganizerDelete(AdministratorPermissionRequiredMixin, FormView):
messages.success(self.request, _('The organizer has been deleted.'))
return redirect(self.get_success_url())
except ProtectedError as e:
err = gettext('The organizer could not be deleted as some constraints (e.g. data created by plug-ins) do not allow it.')
err = gettext(
'The organizer could not be deleted as some constraints (e.g. data created by plug-ins) do not allow it.')
# Unlike deleting events (which is done by regular backend users), this feature can only be used by sysadmins,
# so we expose more technical / less polished information.
@@ -507,7 +512,8 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
@cached_property
def footer_links_formset(self):
return OrganizerFooterLinkFormset(self.request.POST if self.request.method == "POST" else None, organizer=self.object,
return OrganizerFooterLinkFormset(self.request.POST if self.request.method == "POST" else None,
organizer=self.object,
prefix="footer-links", instance=self.object)
def save_footer_links_formset(self, obj):
@@ -1328,6 +1334,95 @@ class WebHookLogsView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin
}))
class GiftCardAcceptanceInviteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, FormView):
model = GiftCardAcceptance
template_name = 'pretixcontrol/organizers/giftcard_acceptance_invite.html'
permission = 'can_change_organizer_settings'
form_class = GiftCardAcceptanceInviteForm
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
'organizer': self.request.organizer,
}
def form_valid(self, form):
self.request.organizer.gift_card_acceptor_acceptance.get_or_create(
acceptor=form.cleaned_data['acceptor'],
reusable_media=form.cleaned_data['reusable_media'],
active=False,
)
self.request.organizer.log_action(
'pretix.giftcards.acceptance.acceptor.invited',
data={'acceptor': form.cleaned_data['acceptor'].slug,
'reusable_media': form.cleaned_data['reusable_media']},
user=self.request.user
)
messages.success(self.request, _('The selected organizer has been invited.'))
return redirect(
reverse('control:organizer.giftcards.acceptance', kwargs={'organizer': self.request.organizer.slug}))
class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = GiftCardAcceptance
template_name = 'pretixcontrol/organizers/giftcard_acceptance_list.html'
permission = 'can_change_organizer_settings'
context_object_name = 'acceptor_acceptance'
paginate_by = 50
def get_queryset(self):
qs = self.request.organizer.gift_card_acceptor_acceptance.select_related(
'acceptor'
).order_by('acceptor__name', 'acceptor_id')
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['issuer_acceptance'] = self.request.organizer.gift_card_issuer_acceptance.select_related(
'issuer'
)
return ctx
@transaction.atomic()
def post(self, request, *args, **kwargs):
if "delete_acceptor" in request.POST:
done = self.request.organizer.gift_card_acceptor_acceptance.filter(
acceptor__slug=request.POST.get("delete_acceptor")
).delete()
if done:
self.request.organizer.log_action(
'pretix.giftcards.acceptance.acceptor.removed',
data={'acceptor': request.POST.get("delete_acceptor")},
user=request.user
)
messages.success(self.request, _('The selected connection has been removed.'))
elif "delete_issuer" in request.POST:
done = self.request.organizer.gift_card_issuer_acceptance.filter(
issuer__slug=request.POST.get("delete_issuer")
).delete()
if done:
self.request.organizer.log_action(
'pretix.giftcards.acceptance.issuer.removed',
data={'issuer': request.POST.get("delete_acceptor")},
user=request.user
)
messages.success(self.request, _('The selected connection has been removed.'))
if "accept_issuer" in request.POST:
done = self.request.organizer.gift_card_issuer_acceptance.filter(
issuer__slug=request.POST.get("accept_issuer")
).update(active=True)
if done:
self.request.organizer.log_action(
'pretix.giftcards.acceptance.issuer.accepted',
data={'issuer': request.POST.get("accept_issuer")},
user=request.user
)
messages.success(self.request, _('The selected connection has been accepted.'))
return redirect(
reverse('control:organizer.giftcards.acceptance', kwargs={'organizer': self.request.organizer.slug}))
class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = GiftCard
template_name = 'pretixcontrol/organizers/giftcards.html'
@@ -1346,39 +1441,6 @@ class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
qs = self.filter_form.filter_qs(qs)
return qs
def post(self, request, *args, **kwargs):
if "add" in request.POST:
o = self.request.user.get_organizers_with_permission(
'can_manage_gift_cards', self.request
).exclude(pk=self.request.organizer.pk).filter(
slug=request.POST.get("add")
).first()
if o:
self.request.organizer.gift_card_issuer_acceptance.get_or_create(
issuer=o
)
self.request.organizer.log_action(
'pretix.giftcards.acceptance.added',
data={'issuer': o.slug},
user=request.user
)
messages.success(self.request, _('The selected gift card issuer has been added.'))
if "del" in request.POST:
o = Organizer.objects.filter(
slug=request.POST.get("del")
).first()
if o:
self.request.organizer.gift_card_issuer_acceptance.filter(
issuer=o
).delete()
self.request.organizer.log_action(
'pretix.giftcards.acceptance.removed',
data={'issuer': o.slug},
user=request.user
)
messages.success(self.request, _('The selected gift card issuer has been removed.'))
return redirect(reverse('control:organizer.giftcards', kwargs={'organizer': self.request.organizer.slug}))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
@@ -1621,7 +1683,8 @@ class ExportMixin:
def exporters(self):
responses = register_multievent_data_exporters.send(self.request.organizer)
raw_exporters = [
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, self.request.organizer)
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events,
self.request.organizer)
for r, response in responses
if response
]
@@ -1629,12 +1692,14 @@ class ExportMixin:
ex for ex in raw_exporters
if (
not isinstance(ex, OrganizerLevelExportMixin) or
self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
self.request.user.has_organizer_permission(self.request.organizer, ex.organizer_required_permission,
self.request)
)
]
return sorted(
raw_exporters,
key=lambda ex: (0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
key=lambda ex: (
0 if ex.category else 1, ex.category or "", 0 if ex.featured else 1, str(ex.verbose_name).lower())
)
def get_context_data(self, **kwargs):
@@ -1691,7 +1756,8 @@ class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, T
data = self.scheduled.export_form_data
else:
if not self.exporter.form.is_valid():
messages.error(self.request, _('There was a problem processing your input. See below for error details.'))
messages.error(self.request,
_('There was a problem processing your input. See below for error details.'))
return self.get(request, *args, **kwargs)
data = self.exporter.form.cleaned_data
@@ -1764,7 +1830,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
else:
initial = {}
return RRuleForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get(
"schedule") == "save" else None,
prefix="rrule",
initial=initial
)
@@ -1779,7 +1846,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
"mail_template": gettext(
"Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
name=str(self.request.organizer.name)
),
"schedule_rrule_time": time(4, 0, 0),
@@ -1787,7 +1855,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
else:
initial = {}
return ScheduledOrganizerExportForm(
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get("schedule") == "save" else None,
data=self.request.POST if self.request.method == 'POST' and self.request.POST.get(
"schedule") == "save" else None,
prefix="schedule",
instance=instance,
initial=initial,
@@ -1804,7 +1873,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
elif not self.exporter:
for s in ctx['scheduled']:
try:
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][0].verbose_name
s.export_verbose_name = [e for e in self.exporters if e.identifier == s.export_identifier][
0].verbose_name
except IndexError:
s.export_verbose_name = "?"
return ctx
@@ -2236,9 +2306,10 @@ class SSOProviderUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['redirect_uri'] = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={
'provider': self.object.pk
})
ctx['redirect_uri'] = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return',
kwargs={
'provider': self.object.pk
})
return ctx
def get_form_kwargs(self):

View File

@@ -293,6 +293,19 @@ def test_giftcard_transact_cross_organizer(token_client, organizer, event, other
assert other_giftcard.transactions.last().acceptor == organizer
@pytest.mark.django_db
def test_giftcard_transact_cross_organizer_inactive(token_client, organizer, event, other_giftcard):
organizer.gift_card_issuer_acceptance.update(active=False)
resp = token_client.post(
'/api/v1/organizers/{}/giftcards/{}/transact/?include_accepted=true'.format(organizer.slug, other_giftcard.pk),
{
'value': '10.00',
},
format='json'
)
assert resp.status_code == 404
@pytest.mark.django_db
def test_giftcard_transact_min_zero(token_client, organizer, event, giftcard):
resp = token_client.post(

View File

@@ -19,14 +19,16 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Order, Organizer, ReusableMedium
from pretix.base.models import (
Event, GiftCardAcceptance, Order, Organizer, ReusableMedium,
)
@pytest.fixture
@@ -49,11 +51,28 @@ def organizer2():
@pytest.fixture
def giftcard2(organizer2):
gc = organizer2.issued_gift_cards.create(secret="ABCDEF", currency="EUR")
gc = organizer2.issued_gift_cards.create(secret="IJKLMNOP", currency="EUR")
gc.transactions.create(value=Decimal('23.00'), acceptor=organizer2)
return gc
@pytest.fixture
def medium2(organizer2):
m = organizer2.reusable_media.create(identifier="ABCDEFGH", type="barcode", active=True)
return m
@pytest.fixture
@scopes_disabled()
def org2_event(organizer2):
e = Event.objects.create(
organizer=organizer2, name='Dummy2', slug='dummy2',
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc),
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf'
)
return e
@pytest.fixture
def customer(organizer, event):
return organizer.customers.create(
@@ -67,6 +86,7 @@ def customer(organizer, event):
TEST_MEDIUM_RES = {
"id": 1,
"organizer": "dummy",
"identifier": "ABCDEFGH",
"type": "barcode",
"active": True,
@@ -357,3 +377,64 @@ def test_medium_autocreate(token_client, organizer):
)
assert resp.status_code == 200
assert resp.data["result"] is None
@pytest.mark.django_db
def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2_event, medium2, giftcard2):
with scopes_disabled():
o = Order.objects.create(
code='FOO', event=org2_event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14"))
medium2.linked_orderposition = op
medium2.linked_giftcard = giftcard2
medium2.save()
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": medium2.type,
"identifier": medium2.identifier,
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is None
gca = GiftCardAcceptance.objects.create(
issuer=organizer2,
acceptor=organizer,
active=True,
reusable_media=False
)
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": medium2.type,
"identifier": medium2.identifier,
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is None
gca.reusable_media = True
gca.save()
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug),
{
"type": medium2.type,
"identifier": medium2.identifier,
},
format='json'
)
assert resp.status_code == 200
assert resp.data["result"] is not None
assert resp.data["result"]["organizer"] == "partner"
assert resp.data["result"]["linked_giftcard"] is not None
assert resp.data["result"]["linked_orderposition"] is None

View File

@@ -51,7 +51,8 @@ def gift_card(organizer):
@pytest.fixture
def admin_user(organizer):
u = User.objects.create_user('dummy@dummy.dummy', 'dummy')
admin_team = Team.objects.create(organizer=organizer, can_manage_gift_cards=True, name='Admin team')
admin_team = Team.objects.create(organizer=organizer, can_manage_gift_cards=True, name='Admin team',
can_change_organizer_settings=True)
admin_team.members.add(u)
return u
@@ -174,24 +175,29 @@ def test_card_detail_view_transact_invalid_value(organizer, admin_user, gift_car
@pytest.mark.django_db
def test_manage_acceptance(organizer, organizer2, admin_user, gift_card, client, team2):
gca = organizer.gift_card_issuer_acceptance.create(issuer=organizer2, active=False)
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/giftcards', {
'add': organizer2.slug
client.post('/control/organizer/dummy/giftcards/acceptance', {
'accept_issuer': organizer2.slug
})
assert organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
client.post('/control/organizer/dummy/giftcards', {
'del': organizer2.slug
gca.refresh_from_db()
assert gca.active
client.post('/control/organizer/dummy/giftcards/acceptance', {
'delete_issuer': organizer2.slug
})
assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
@pytest.mark.django_db
def test_manage_acceptance_permission_required(organizer, organizer2, admin_user, gift_card, client):
client.login(email='dummy@dummy.dummy', password='dummy')
client.post('/control/organizer/dummy/giftcards', {
'add': organizer2.slug
client.post('/control/organizer/dummy/giftcards/acceptance/invite', {
'acceptor': organizer2.slug
})
assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists()
assert organizer.gift_card_acceptor_acceptance.filter(acceptor=organizer2).exists()
client.post('/control/organizer/dummy/giftcards/acceptance', {
'delete_acceptor': organizer2.slug
})
assert not organizer.gift_card_acceptor_acceptance.filter(acceptor=organizer2).exists()
@pytest.mark.django_db

View File

@@ -219,6 +219,8 @@ organizer_urls = [
'organizer/abc/giftcard/add',
'organizer/abc/giftcard/1/',
'organizer/abc/giftcard/1/edit',
'organizer/abc/giftcards/acceptance',
'organizer/abc/giftcards/acceptance/invite',
]
@@ -552,6 +554,8 @@ organizer_permission_urls = [
("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200),
("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404),
("can_manage_gift_cards", "organizer/dummy/giftcard/1/edit", 404),
("can_change_organizer_settings", "organizer/dummy/giftcards/acceptance", 200),
("can_change_organizer_settings", "organizer/dummy/giftcards/acceptance/invite", 200),
# bank transfer
("can_change_orders", "organizer/dummy/banktransfer/import/", 200),

View File

@@ -1676,6 +1676,24 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
assert gc.issuer == orga2
assert gc.transactions.last().acceptor == self.orga
def test_giftcard_cross_organizer_inactive(self):
self.orga.issued_gift_cards.create(currency="EUR")
orga2 = Organizer.objects.create(slug="foo2", name="foo2")
gc = orga2.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=23, acceptor=orga2)
self.orga.gift_card_issuer_acceptance.create(issuer=orga2, active=False)
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'giftcard': gc.secret
}, follow=True)
assert 'This gift card is not known.' in response.content.decode()
def test_giftcard_in_test_mode(self):
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20, acceptor=self.orga)