diff --git a/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst
index 1be7a58e0..89ab80529 100644
--- a/doc/api/resources/reusablemedia.rst
+++ b/doc/api/resources/reusablemedia.rst
@@ -19,6 +19,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the medium
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
+organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``.
active boolean Whether this medium may be used.
created datetime Date of creation
@@ -67,6 +68,7 @@ Endpoints
"results": [
{
"id": 1,
+ "organizer": "bigevents",
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
@@ -123,6 +125,7 @@ Endpoints
{
"id": 1,
+ "organizer": "bigevents",
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
@@ -152,6 +155,9 @@ Endpoints
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
medium behind the scenes.
+ This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance
+ agreement. In this case, only linked gift cards will be returned, no order position or customer records,
+
**Example request**:
.. sourcecode:: http
@@ -176,6 +182,7 @@ Endpoints
{
"id": 1,
+ "organizer": "bigevents",
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
@@ -235,6 +242,7 @@ Endpoints
{
"id": 1,
+ "organizer": "bigevents",
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
@@ -291,6 +299,7 @@ Endpoints
{
"id": 1,
+ "organizer": "bigevents",
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
diff --git a/src/pretix/api/serializers/media.py b/src/pretix/api/serializers/media.py
index dece0ef35..e4b3d09c1 100644
--- a/src/pretix/api/serializers/media.py
+++ b/src/pretix/api/serializers/media.py
@@ -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',
diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index 9e7413a43..696646b03 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -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)
diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py
index 2d5ef7e84..7624afd83 100644
--- a/src/pretix/api/views/media.py
+++ b/src/pretix/api/views/media.py
@@ -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})
diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py
index 454c5a47b..51e714ed9 100644
--- a/src/pretix/base/exporters/orderlist.py
+++ b/src/pretix/base/exporters/orderlist.py
@@ -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
diff --git a/src/pretix/base/migrations/0242_auto_20230512_1008.py b/src/pretix/base/migrations/0242_auto_20230512_1008.py
new file mode 100644
index 000000000..222aa00e5
--- /dev/null
+++ b/src/pretix/base/migrations/0242_auto_20230512_1008.py
@@ -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')},
+ ),
+ ]
diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py
index 387ca6bf2..59f1ba808 100644
--- a/src/pretix/base/models/giftcards.py
+++ b/src/pretix/base/models/giftcards.py
@@ -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:
diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py
index 38b998043..8edcc4031 100644
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -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
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index c955551ab..15a0e8e11 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -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
diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py
index cac79721d..eeff28d72 100644
--- a/src/pretix/control/logdisplay.py
+++ b/src/pretix/control/logdisplay.py
@@ -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.'),
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index 89ec4ee3a..41e68ca97 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -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:
diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/payment.html b/src/pretix/control/templates/pretixcontrol/giftcards/payment.html
index 39ba7aab9..d66803a61 100644
--- a/src/pretix/control/templates/pretixcontrol/giftcards/payment.html
+++ b/src/pretix/control/templates/pretixcontrol/giftcards/payment.html
@@ -6,6 +6,12 @@
{{ gc.secret }}
+ {% if gc.issuer != request.organizer %}
+
+
+ {{ gc.issuer }}
+
+ {% endif %}
{% trans "Issuer" %}
{{ gc.issuer }}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_invite.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_invite.html
new file mode 100644
index 000000000..688ebbbe6
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_invite.html
@@ -0,0 +1,19 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load urlreplace %}
+{% load bootstrap3 %}
+{% load money %}
+{% block inner %}
+
+ {% trans "Invite organizer" %}
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html
new file mode 100644
index 000000000..264d0ded8
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_acceptance_list.html
@@ -0,0 +1,150 @@
+{% extends "pretixcontrol/organizers/base.html" %}
+{% load i18n %}
+{% load urlreplace %}
+{% load bootstrap3 %}
+{% load money %}
+{% block inner %}
+
+ {% trans "Gift cards acceptance" %}
+
+
+ {% blocktrans trimmed %}
+ This feature allows you to configure acceptance of gift cards across multiple organizer accounts.
+ {% endblocktrans %}
+
+
+
+ {% include "pretixcontrol/pagination.html" %}
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html
index 1daa0c664..7e5438e71 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html
@@ -99,44 +99,4 @@
{% include "pretixcontrol/pagination.html" %}
{% endif %}
- {% if not is_paginated or page_obj.number == 1 %}
-
- {% endif %}
{% endblock %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 869735223..03816e54c 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -176,6 +176,10 @@ urlpatterns = [
re_path(r'^organizer/(?P[^/]+)/giftcard/(?P[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
re_path(r'^organizer/(?P[^/]+)/giftcard/(?P[^/]+)/edit$', organizer.GiftCardUpdateView.as_view(),
name='organizer.giftcard.edit'),
+ re_path(r'^organizer/(?P[^/]+)/giftcards/acceptance$', organizer.GiftCardAcceptanceListView.as_view(),
+ name='organizer.giftcards.acceptance'),
+ re_path(r'^organizer/(?P[^/]+)/giftcards/acceptance/invite$', organizer.GiftCardAcceptanceInviteView.as_view(),
+ name='organizer.giftcards.acceptance.invite'),
re_path(r'^organizer/(?P[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
re_path(r'^organizer/(?P[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
name='organizer.webhook.add'),
diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py
index 2e70e0b39..3cf86129f 100644
--- a/src/pretix/control/views/organizer.py
+++ b/src/pretix/control/views/organizer.py
@@ -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):
diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py
index f204484b8..74d67dcdc 100644
--- a/src/tests/api/test_giftcards.py
+++ b/src/tests/api/test_giftcards.py
@@ -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(
diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py
index 7bd58cb59..516abe80f 100644
--- a/src/tests/api/test_reusable_media.py
+++ b/src/tests/api/test_reusable_media.py
@@ -19,14 +19,16 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
-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
diff --git a/src/tests/control/test_giftcards.py b/src/tests/control/test_giftcards.py
index 949d875fa..e598f118a 100644
--- a/src/tests/control/test_giftcards.py
+++ b/src/tests/control/test_giftcards.py
@@ -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
diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py
index 9c7bf626c..710c71ff3 100644
--- a/src/tests/control/test_permissions.py
+++ b/src/tests/control/test_permissions.py
@@ -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),
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py
index cd9100cdd..b9409eb7b 100644
--- a/src/tests/presale/test_checkout.py
+++ b/src/tests/presale/test_checkout.py
@@ -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)