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

+
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+
+{% 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 %} +

+
+ {% csrf_token %} +

+ {% trans "Other organizers you accept gift cards from" %} +

+ + {% if issuer_acceptance|length == 0 and not filter_form.filtered %} +

+ {% 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 %} +

+ {% else %} +
+ + + + + + + + + + + {% for gca in issuer_acceptance %} + + + + + + + {% endfor %} + +
{% trans "Organizer" %}{% trans "Status" %}{% trans "Reusable media" %}
+ {{ gca.issuer.name }}
{{ gca.issuer.slug }} +
+ {% if gca.active %} + {% trans "active" %} + {% else %} + {% trans "invited" %} + {% endif %} + + {% if gca.reusable_media %} + {% trans "active" %} + {% else %} + {% trans "disabled" %} + {% endif %} + + {% if gca.active %} + + {% else %} + + + {% endif %} +
+
+ {% endif %} + +
+
+ {% csrf_token %} +

+ {% trans "Other organizers accepting gift cards from you" %} +

+

+ {% 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 %} +

+

+ {% 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 %} +

+ + {% trans "Invite new organizer" %} + +
+ + + + + + + + + + + {% for gca in acceptor_acceptance %} + + + + + + + {% endfor %} + +
{% trans "Organizer" %}{% trans "Status" %}{% trans "Reusable media" %}
+ {{ gca.acceptor.name }}
{{ gca.acceptor.slug }} +
+ {% if gca.active %} + {% trans "active" %} + {% else %} + {% trans "invited" %} + {% endif %} + + {% if gca.reusable_media %} + {% trans "active" %} + {% else %} + {% trans "disabled" %} + {% endif %} + + +
+
+
+ {% 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 %} -
- {% csrf_token %} -
- {% trans "Accepted gift cards of other organizers" %} -

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

- -
-
- {% 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)