From d0b449ea8919c8d4fc76e3682e381ef3ca57b427 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 3 Apr 2023 10:45:22 +0200 Subject: [PATCH] Reusable media (#3131) Co-authored-by: Martin Gross --- doc/api/deviceauth.rst | 1 - doc/api/resources/checkin.rst | 10 +- doc/api/resources/checkinlists.rst | 4 +- doc/api/resources/events.rst | 8 + doc/api/resources/index.rst | 1 + doc/api/resources/items.rst | 17 + doc/api/resources/orders.rst | 1 + doc/api/resources/organizers.rst | 1 + doc/api/resources/reusablemedia.rst | 317 ++++++++++++++++ doc/api/resources/teams.rst | 5 + src/pretix/api/auth/devicesecurity.py | 2 + src/pretix/api/serializers/checkin.py | 2 + src/pretix/api/serializers/event.py | 17 + src/pretix/api/serializers/item.py | 4 +- src/pretix/api/serializers/media.py | 128 +++++++ src/pretix/api/serializers/order.py | 36 +- src/pretix/api/serializers/organizer.py | 8 +- src/pretix/api/serializers/settings.py | 6 + src/pretix/api/urls.py | 7 +- src/pretix/api/views/checkin.py | 194 +++++----- src/pretix/api/views/event.py | 3 +- src/pretix/api/views/media.py | 160 ++++++++ src/pretix/api/views/order.py | 14 +- src/pretix/api/views/organizer.py | 3 +- src/pretix/base/media.py | 116 ++++++ .../base/migrations/0236_reusable_media.py | 77 ++++ src/pretix/base/models/__init__.py | 1 + src/pretix/base/models/checkin.py | 6 + src/pretix/base/models/customers.py | 1 + src/pretix/base/models/devices.py | 3 +- src/pretix/base/models/items.py | 52 +++ src/pretix/base/models/media.py | 125 +++++++ src/pretix/base/models/organizer.py | 6 + src/pretix/base/pdf.py | 8 + src/pretix/base/services/cart.py | 10 + src/pretix/base/services/checkin.py | 3 +- src/pretix/base/services/orders.py | 30 ++ src/pretix/base/settings.py | 85 ++++- src/pretix/control/forms/filter.py | 56 +++ src/pretix/control/forms/item.py | 10 + src/pretix/control/forms/organizer.py | 150 +++++++- src/pretix/control/logdisplay.py | 4 + src/pretix/control/navigation.py | 10 + .../templates/pretixcontrol/item/index.html | 7 + .../templates/pretixcontrol/order/index.html | 8 + .../pretixcontrol/organizers/edit.html | 64 ++++ .../organizers/reusable_media.html | 113 ++++++ .../organizers/reusable_medium.html | 93 +++++ .../organizers/reusable_medium_edit.html | 32 ++ .../pretixcontrol/organizers/team_edit.html | 1 + src/pretix/control/urls.py | 9 + src/pretix/control/views/orders.py | 2 +- src/pretix/control/views/organizer.py | 109 +++++- src/pretix/control/views/typeahead.py | 102 ++++- src/tests/api/conftest.py | 1 + src/tests/api/test_checkinrpc.py | 70 +++- src/tests/api/test_events.py | 8 +- src/tests/api/test_items.py | 2 + src/tests/api/test_order_create.py | 93 ++++- src/tests/api/test_orders.py | 4 +- src/tests/api/test_organizers.py | 3 +- src/tests/api/test_reusable_media.py | 352 ++++++++++++++++++ src/tests/api/test_teams.py | 4 +- src/tests/base/test_orders.py | 31 ++ src/tests/control/test_giftcards.py | 25 ++ src/tests/control/test_permissions.py | 7 + src/tests/control/test_reusable_media.py | 167 +++++++++ 67 files changed, 2876 insertions(+), 133 deletions(-) create mode 100644 doc/api/resources/reusablemedia.rst create mode 100644 src/pretix/api/serializers/media.py create mode 100644 src/pretix/api/views/media.py create mode 100644 src/pretix/base/media.py create mode 100644 src/pretix/base/migrations/0236_reusable_media.py create mode 100644 src/pretix/base/models/media.py create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/reusable_medium_edit.html create mode 100644 src/tests/api/test_reusable_media.py create mode 100644 src/tests/control/test_reusable_media.py diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index f3fc0972ab..1d946860c2 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -225,4 +225,3 @@ You can get three response codes: "subevent": 23, "checkinlist": 5 } - diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index 79f9417f4c..551294de1a 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -13,6 +13,10 @@ failed scans. The endpoints listed on this page have been added. +.. versionchanged:: 4.18 + + The ``source_type`` parameter has been added. + .. _`rest-checkin-redeem`: Checking a ticket in @@ -28,6 +32,7 @@ Checking a ticket in passed needs to be from a distinct event. :. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import logging +from decimal import Decimal + +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.api.serializers.order import OrderPositionSerializer +from pretix.api.serializers.organizer import ( + CustomerSerializer, GiftCardSerializer, +) +from pretix.base.models import Order, OrderPosition, ReusableMedium + +logger = logging.getLogger(__name__) + + +class NestedOrderMiniSerializer(I18nAwareModelSerializer): + event = serializers.SlugRelatedField(slug_field='slug', read_only=True) + + class Meta: + model = Order + fields = ['code', 'event'] + + +class NestedOrderPositionSerializer(OrderPositionSerializer): + order = NestedOrderMiniSerializer() + + +class NestedGiftCardSerializer(GiftCardSerializer): + + def to_representation(self, instance): + d = super().to_representation(instance) + if hasattr(instance, 'cached_value'): + d['value'] = str(Decimal(instance.cached_value).quantize(Decimal("0.01"))) + else: + d['value'] = str(Decimal(instance.value).quantize(Decimal("0.01"))) + return d + + +class ReusableMediaSerializer(I18nAwareModelSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): + self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True) + else: + self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( + required=False, + allow_null=True, + queryset=self.context['organizer'].issued_gift_cards.all() + ) + + if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): + self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) + else: + self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( + required=False, + allow_null=True, + queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), + ) + + if 'customer' in self.context['request'].query_params.getlist('expand'): + self.fields['customer'] = CustomerSerializer(read_only=True) + else: + self.fields['customer'] = serializers.SlugRelatedField( + required=False, + allow_null=True, + slug_field='identifier', + queryset=self.context['organizer'].customers.all() + ) + + def validate(self, data): + data = super().validate(data) + if 'type' in data and 'identifier' in data: + qs = self.context['organizer'].reusable_media.filter( + identifier=data['identifier'], type=data['type'] + ) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise ValidationError( + {'identifier': _('A medium with the same identifier and type already exists in your organizer account.')} + ) + return data + + class Meta: + model = ReusableMedium + fields = ( + 'id', + 'created', + 'updated', + 'type', + 'identifier', + 'active', + 'expires', + 'customer', + 'linked_orderposition', + 'linked_giftcard', + 'info', + 'notes', + ) + + +class MediaLookupInputSerializer(serializers.Serializer): + type = serializers.CharField(required=True) + identifier = serializers.CharField(required=True) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 4564d9676e..5c73f9cb01 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -33,6 +33,7 @@ from django.utils.encoding import force_str from django.utils.timezone import now from django.utils.translation import gettext_lazy from django_countries.fields import Country +from django_scopes import scopes_disabled from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import SlugRelatedField @@ -48,8 +49,8 @@ from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, - ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat, - SubEvent, TaxRule, Voucher, + ItemVariation, Order, OrderPosition, Question, QuestionAnswer, + ReusableMedium, Seat, SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -356,6 +357,9 @@ class PdfDataSerializer(serializers.Field): def to_representation(self, instance: OrderPosition): res = {} + if 'event' not in self.context: + return {} + ev = instance.subevent or instance.order.event with language(instance.order.locale, instance.order.event.settings.region): # This needs to have some extra performance improvements to avoid creating hundreds of queries when @@ -784,13 +788,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): required=False, allow_null=True) country = CompatibleCountryField(source='*') requested_valid_from = serializers.DateTimeField(required=False, allow_null=True) + use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(), + required=False, allow_null=True) class Meta: model = OrderPosition fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled', 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until', - 'requested_valid_from') + 'requested_valid_from', 'use_reusable_medium') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -799,6 +805,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): v.required = False v.allow_blank = True v.allow_null = True + with scopes_disabled(): + if 'use_reusable_medium' in self.fields: + self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all() def validate_secret(self, secret): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): @@ -807,6 +816,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): ) return secret + def validate_use_reusable_medium(self, m): + if m.organizer_id != self.context['event'].organizer_id: + raise ValidationError( + 'The specified medium does not belong to this organizer.' + ) + return m + def validate_item(self, item): if item.event != self.context['event']: raise ValidationError( @@ -1264,7 +1280,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos_data['attendee_name_parts'] = { '_legacy': attendee_name } - pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas'}) + pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'}) if simulate: pos.order = order._wrapped else: @@ -1332,6 +1348,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): # Save instances for pos_data in positions_data: answers_data = pos_data.pop('answers', []) + use_reusable_medium = pos_data.pop('use_reusable_medium', None) pos = pos_data['__instance'] pos._calculate_tax() @@ -1370,6 +1387,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer): answ = pos.answers.create(**answ_data) answ.options.add(*options) + if use_reusable_medium: + use_reusable_medium.linked_orderposition = pos + use_reusable_medium.save(update_fields=['linked_orderposition']) + use_reusable_medium.log_action( + 'pretix.reusable_medium.linked_orderposition.changed', + data={ + 'by_order': order.code, + 'linked_orderposition': pos.pk, + } + ) + if not simulate: for cp in delete_cps: if cp.addon_to_id: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 1d14fc4f10..a4daefab98 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -183,7 +183,7 @@ class TeamSerializer(serializers.ModelSerializer): 'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', - 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers' + 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media' ) def validate(self, data): @@ -333,6 +333,12 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'cookie_consent_dialog_text_secondary', 'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_no', + 'reusable_media_active', + 'reusable_media_type_barcode', + 'reusable_media_type_barcode_identifier_length', + 'reusable_media_type_nfc_uid', + 'reusable_media_type_nfc_uid_autocreate_giftcard', + 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/api/serializers/settings.py b/src/pretix/api/serializers/settings.py index 494477d02a..51b89ee8b2 100644 --- a/src/pretix/api/serializers/settings.py +++ b/src/pretix/api/serializers/settings.py @@ -36,6 +36,7 @@ logger = logging.getLogger(__name__) class SettingsSerializer(serializers.Serializer): default_fields = [] + readonly_fields = [] def __init__(self, *args, **kwargs): self.changed_data = [] @@ -59,8 +60,13 @@ class SettingsSerializer(serializers.Serializer): f.parent = self self.fields[fname] = f + def validate(self, attrs): + return {k: v for k, v in attrs.items() if k not in self.readonly_fields} + def update(self, instance: HierarkeyProxy, validated_data): for attr, value in validated_data.items(): + if attr in self.readonly_fields: + continue if isinstance(value, FieldFile): # Delete old file fname = instance.get(attr, as_type=File) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index e6708e9a86..2b88b170ea 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -42,9 +42,9 @@ from rest_framework import routers from pretix.api.views import cart from .views import ( - checkin, device, discount, event, exporters, idempotency, item, oauth, - order, organizer, shredders, upload, user, version, voucher, waitinglist, - webhooks, + checkin, device, discount, event, exporters, idempotency, item, media, + oauth, order, organizer, shredders, upload, user, version, voucher, + waitinglist, webhooks, ) router = routers.DefaultRouter() @@ -59,6 +59,7 @@ orga_router.register(r'giftcards', organizer.GiftCardViewSet) orga_router.register(r'customers', organizer.CustomerViewSet) orga_router.register(r'memberships', organizer.MembershipViewSet) orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) +orga_router.register(r'reusablemedia', media.ReusableMediaViewSet) orga_router.register(r'teams', organizer.TeamViewSet) orga_router.register(r'devices', organizer.DeviceViewSet) orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index d2eef3856c..f47688d3b4 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -59,7 +59,7 @@ from pretix.api.views.order import OrderPositionFilter from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, - Question, RevokedTicketSecret, TeamAPIToken, + Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, ) from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, @@ -396,7 +396,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, - legacy_url_support=False): + source_type='barcode', legacy_url_support=False): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -422,6 +422,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, common_checkin_args = dict( raw_barcode=raw_barcode, + raw_source_type=source_type, type=checkin_type, list=checkinlists[0], datetime=datetime, @@ -433,7 +434,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, raw_barcode_for_checkin = None from_revoked_secret = False - # 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or + # 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or # parent secret queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by( F('addon_to').asc(nulls_first=True) @@ -457,98 +458,111 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, # 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it # might be a revoked one that we actually know (-> error, but with better error message and logging and - # with respecting the force option). + # with respecting the force option), or it's a reusable medium (-> proceed with that) if not op_candidates: - revoked_matches = list(RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode)) - if len(revoked_matches) == 0: - checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ - 'datetime': datetime, - 'type': checkin_type, - 'list': checkinlists[0].pk, - 'barcode': raw_barcode, - 'searched_lists': [cl.pk for cl in checkinlists] - }, user=user, auth=auth) + try: + media = ReusableMedium.objects.select_related('linked_orderposition').active().get( + organizer_id=checkinlists[0].event.organizer_id, + type=source_type, + identifier=raw_barcode, + linked_orderposition__isnull=False, + ) + raw_barcode_for_checkin = raw_barcode + except ReusableMedium.DoesNotExist: + revoked_matches = list( + RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode)) + if len(revoked_matches) == 0: + checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ + 'datetime': datetime, + 'type': checkin_type, + 'list': checkinlists[0].pk, + 'barcode': raw_barcode, + 'searched_lists': [cl.pk for cl in checkinlists] + }, user=user, auth=auth) - for cl in checkinlists: - for k, s in cl.event.ticket_secret_generators.items(): + for cl in checkinlists: + for k, s in cl.event.ticket_secret_generators.items(): + try: + parsed = s.parse_secret(raw_barcode) + common_checkin_args.update({ + 'raw_item': parsed.item, + 'raw_variation': parsed.variation, + 'raw_subevent': parsed.subevent, + }) + except: + pass + + Checkin.objects.create( + position=None, + successful=False, + error_reason=Checkin.REASON_INVALID, + **common_checkin_args, + ) + + if force and legacy_url_support and isinstance(auth, Device): + # There was a bug in libpretixsync: If you scanned a ticket in offline mode that was + # valid at the time but no longer exists at time of upload, the device would retry to + # upload the same scan over and over again. Since we can't update all devices quickly, + # here's a dirty workaround to make it stop. try: - parsed = s.parse_secret(raw_barcode) - common_checkin_args.update({ - 'raw_item': parsed.item, - 'raw_variation': parsed.variation, - 'raw_subevent': parsed.subevent, - }) - except: + brand = auth.software_brand + ver = parse(auth.software_version) + legacy_mode = ( + (brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or + (brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or + (brand == 'pretixSCAN' and ver < parse('1.11.2')) + ) + if legacy_mode: + return Response({ + 'status': 'error', + 'reason': Checkin.REASON_ALREADY_REDEEMED, + 'reason_explanation': None, + 'require_attention': False, + '__warning': 'Compatibility hack active due to detected old pretixSCAN version', + }, status=400) + except: # we don't care e.g. about invalid version numbers pass - Checkin.objects.create( - position=None, - successful=False, - error_reason=Checkin.REASON_INVALID, - **common_checkin_args, - ) - - if force and legacy_url_support and isinstance(auth, Device): - # There was a bug in libpretixsync: If you scanned a ticket in offline mode that was - # valid at the time but no longer exists at time of upload, the device would retry to - # upload the same scan over and over again. Since we can't update all devices quickly, - # here's a dirty workaround to make it stop. - try: - brand = auth.software_brand - ver = parse(auth.software_version) - legacy_mode = ( - (brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or - (brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or - (brand == 'pretixSCAN' and ver < parse('1.11.2')) - ) - if legacy_mode: - return Response({ - 'status': 'error', - 'reason': Checkin.REASON_ALREADY_REDEEMED, - 'reason_explanation': None, - 'require_attention': False, - '__warning': 'Compatibility hack active due to detected old pretixSCAN version', - }, status=400) - except: # we don't care e.g. about invalid version numbers - pass - - return Response({ - 'detail': 'Not found.', # for backwards compatibility - 'status': 'error', - 'reason': Checkin.REASON_INVALID, - 'reason_explanation': None, - 'require_attention': False, - 'list': MiniCheckinListSerializer(checkinlists[0]).data, - }, status=404) - elif revoked_matches and force: - op_candidates = [revoked_matches[0].position] - if list_by_event[revoked_matches[0].event_id].addon_match: - op_candidates += list(revoked_matches[0].position.addons.all()) - raw_barcode_for_checkin = raw_barcode - from_revoked_secret = True + return Response({ + 'detail': 'Not found.', # for backwards compatibility + 'status': 'error', + 'reason': Checkin.REASON_INVALID, + 'reason_explanation': None, + 'require_attention': False, + 'list': MiniCheckinListSerializer(checkinlists[0]).data, + }, status=404) + elif revoked_matches and force: + op_candidates = [revoked_matches[0].position] + if list_by_event[revoked_matches[0].event_id].addon_match: + op_candidates += list(revoked_matches[0].position.addons.all()) + raw_barcode_for_checkin = raw_barcode_for_checkin or raw_barcode + from_revoked_secret = True + else: + op = revoked_matches[0].position + op.order.log_action('pretix.event.checkin.revoked', data={ + 'datetime': datetime, + 'type': checkin_type, + 'list': list_by_event[revoked_matches[0].event_id].pk, + 'barcode': raw_barcode + }, user=user, auth=auth) + common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id] + Checkin.objects.create( + position=op, + successful=False, + error_reason=Checkin.REASON_REVOKED, + **common_checkin_args + ) + return Response({ + 'status': 'error', + 'reason': Checkin.REASON_REVOKED, + 'reason_explanation': None, + 'require_attention': False, + 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[ + 0].event)).data, + 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, + }, status=400) else: - op = revoked_matches[0].position - op.order.log_action('pretix.event.checkin.revoked', data={ - 'datetime': datetime, - 'type': checkin_type, - 'list': list_by_event[revoked_matches[0].event_id].pk, - 'barcode': raw_barcode - }, user=user, auth=auth) - common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id] - Checkin.objects.create( - position=op, - successful=False, - error_reason=Checkin.REASON_REVOKED, - **common_checkin_args - ) - return Response({ - 'status': 'error', - 'reason': Checkin.REASON_REVOKED, - 'reason_explanation': None, - 'require_attention': False, - 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[0].event)).data, - 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, - }, status=400) + op_candidates = [media.linked_orderposition] + list(media.linked_orderposition.addons.all()) # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out @@ -634,6 +648,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, auth=auth, type=checkin_type, raw_barcode=raw_barcode_for_checkin, + raw_source_type=source_type, from_revoked_secret=from_revoked_secret, ) except RequiredQuestionsError as e: @@ -812,6 +827,7 @@ class CheckinRPCRedeemView(views.APIView): return _redeem_process( checkinlists=s.validated_data['lists'], raw_barcode=s.validated_data['secret'], + source_type=s.validated_data['source_type'], answers_data=s.validated_data.get('answers'), datetime=s.validated_data.get('datetime') or now(), force=s.validated_data['force'], diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index b8e939f662..f8ff850aeb 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -542,7 +542,8 @@ class EventSettingsView(views.APIView): fname: { 'value': s.data[fname], 'label': getattr(field, '_label', fname), - 'help_text': getattr(field, '_help_text', None) + 'help_text': getattr(field, '_help_text', None), + 'readonly': fname in s.readonly_fields, } for fname, field in s.fields.items() }) return Response(s.data) diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py new file mode 100644 index 0000000000..7c431bcda6 --- /dev/null +++ b/src/pretix/api/views/media.py @@ -0,0 +1,160 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from decimal import Decimal + +import django_filters +from django.db import transaction +from django.db.models import OuterRef, Prefetch, Subquery, Sum +from django.db.models.functions import Coalesce +from django.utils.timezone import now +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from django_scopes import scopes_disabled +from rest_framework import viewsets, serializers +from rest_framework.decorators import action +from rest_framework.exceptions import MethodNotAllowed +from rest_framework.filters import OrderingFilter +from rest_framework.response import Response + +from pretix.api.serializers.media import ( + MediaLookupInputSerializer, ReusableMediaSerializer, +) +from pretix.base.media import MEDIA_TYPES +from pretix.base.models import ( + Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium, +) +from pretix.helpers import OF_SELF +from pretix.helpers.dicts import merge_dicts + +with scopes_disabled(): + class ReusableMediumFilter(FilterSet): + identifier = django_filters.CharFilter(field_name='identifier') + type = django_filters.CharFilter(field_name='type') + customer = django_filters.CharFilter(field_name='customer__identifier') + updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte') + created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') + + class Meta: + model = ReusableMedium + fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard'] + + +class ReusableMediaViewSet(viewsets.ModelViewSet): + serializer_class = ReusableMediaSerializer + queryset = ReusableMedium.objects.none() + permission = 'can_manage_reusable_media' + write_permission = 'can_manage_reusable_media' + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('-updated', '-id') + ordering_fields = ('created', 'updated', 'identifier', 'type', 'id') + filterset_class = ReusableMediumFilter + + def get_queryset(self): + s = GiftCardTransaction.objects.filter( + card=OuterRef('pk') + ).order_by().values('card').annotate(s=Sum('value')).values('s') + return self.request.organizer.reusable_media.prefetch_related( + Prefetch( + 'linked_orderposition', + queryset=OrderPosition.objects.select_related( + 'order', 'order__event', 'order__event__organizer', 'seat', + ).prefetch_related( + Prefetch('checkins', queryset=Checkin.objects.all()), + 'answers', 'answers__options', 'answers__question', + ) + ), + Prefetch( + 'linked_giftcard', + queryset=GiftCard.objects.annotate( + cached_value=Coalesce(Subquery(s), Decimal('0.00')) + ) + ) + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + inst.log_action( + 'pretix.reusable_medium.created', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + + @transaction.atomic() + def perform_update(self, serializer): + ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) + inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type) + inst.log_action( + 'pretix.reusable_medium.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + def perform_destroy(self, instance): + raise MethodNotAllowed("Media cannot be deleted.") + + @action(methods=["POST"], detail=False) + def lookup(self, request, *args, **kwargs): + s = MediaLookupInputSerializer( + data=request.data, + ) + s.is_valid(raise_exception=True) + + try: + m = ReusableMedium.objects.get( + type=s.validated_data["type"], + identifier=s.validated_data["identifier"], + organizer=request.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}) + + @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce + def list(self, request, **kwargs): + date = serializers.DateTimeField().to_representation(now()) + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + resp = self.get_paginated_response(serializer.data) + resp['X-Page-Generated'] = date + return resp + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, headers={'X-Page-Generated': date}) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 1edb414eed..978d265a58 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -244,7 +244,8 @@ class OrderViewSet(viewsets.ModelViewSet): Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related( Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property')) )), - Prefetch('addons', opq.select_related('item', 'variation', 'seat')) + Prefetch('addons', opq.select_related('item', 'variation', 'seat')), + 'linked_media', ).select_related('seat', 'addon_to', 'addon_to__seat') ) else: @@ -639,13 +640,11 @@ class OrderViewSet(viewsets.ModelViewSet): raise ValidationError(_('One of the selected products is not available in the selected country.')) send_mail = serializer._send_mail order = serializer.instance + if not order.pk: - # Simulation + # Simulation -- exit here serializer = SimulatedOrderSerializer(order, context=serializer.context) return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - prefetch_related_objects([order], self._positions_prefetch(request)) - serializer = OrderSerializer(order, context=serializer.context) order.log_action( 'pretix.event.order.placed', @@ -679,6 +678,10 @@ class OrderViewSet(viewsets.ModelViewSet): if gen_invoice: invoice = generate_invoice(order, trigger_pdf=True) + # Refresh serializer only after running signals + prefetch_related_objects([order], self._positions_prefetch(request)) + serializer = OrderSerializer(order, context=serializer.context) + if send_mail: free_flow = ( payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and @@ -1005,6 +1008,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet): Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property')) )), + 'linked_media', Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related( Prefetch( 'positions', diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 6cf6cf2619..5f88166c33 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -457,7 +457,8 @@ class OrganizerSettingsView(views.APIView): fname: { 'value': s.data[fname], 'label': getattr(field, '_label', fname), - 'help_text': getattr(field, '_help_text', None) + 'help_text': getattr(field, '_help_text', None), + 'readonly': fname in s.readonly_fields, } for fname, field in s.fields.items() }) return Response(s.data) diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py new file mode 100644 index 0000000000..d96a32f651 --- /dev/null +++ b/src/pretix/base/media.py @@ -0,0 +1,116 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.db import transaction +from django.utils.crypto import get_random_string +from django.utils.translation import gettext_lazy as _ + + +class BaseMediaType: + medium_created_by_server = False + supports_orderposition = False + supports_giftcard = False + + @property + def identifier(self): + raise NotImplementedError() + + @property + def verbose_name(self): + raise NotImplementedError() + + def generate_identifier(self, organizer): + if self.medium_created_by_server: + raise NotImplementedError() + else: + raise ValueError("Media type does not allow to generate identifier") + + def is_active(self, organizer): + return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False) + + def handle_unknown(self, organizer, identifier, user, auth): + pass + + def __str__(self): + return str(self.verbose_name) + + +class BarcodePlainMediaType(BaseMediaType): + identifier = 'barcode' + verbose_name = _('Barcode / QR-Code') + medium_created_by_server = True + supports_giftcard = False + supports_orderposition = True + + def generate_identifier(self, organizer): + return get_random_string( + length=organizer.settings.reusable_media_type_barcode_identifier_length, + # Exclude o,0,1,i to avoid confusion with bad fonts/printers + # We use upper case to make collisions with ticket secrets less likely + allowed_chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + ) + + +class NfcUidMediaType(BaseMediaType): + identifier = 'nfc_uid' + verbose_name = _('NFC UID-based') + medium_created_by_server = False + supports_giftcard = True + supports_orderposition = False + + def handle_unknown(self, organizer, identifier, user, auth): + from pretix.base.models import GiftCard, ReusableMedium + + if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool): + if identifier.startswith("08"): + # Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random + # UIDs on every read, so they won't be useful. + return + with transaction.atomic(): + gc = GiftCard.objects.create( + issuer=organizer, + expires=organizer.default_gift_card_expiry, + currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), + ) + m = ReusableMedium.objects.create( + type=self.identifier, + identifier=identifier, + organizer=organizer, + active=True, + linked_giftcard=gc + ) + m.log_action( + 'pretix.reusable_medium.created.auto', + user=user, auth=auth, + ) + gc.log_action( + 'pretix.giftcards.created', + user=user, auth=auth, + ) + return m + + +MEDIA_TYPES = { + m.identifier: m for m in [ + BarcodePlainMediaType(), + NfcUidMediaType(), + ] +} diff --git a/src/pretix/base/migrations/0236_reusable_media.py b/src/pretix/base/migrations/0236_reusable_media.py new file mode 100644 index 0000000000..95eb93890c --- /dev/null +++ b/src/pretix/base/migrations/0236_reusable_media.py @@ -0,0 +1,77 @@ +# Generated by Django 3.2.18 on 2023-02-20 12:46 + +import django.core.serializers.json +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +def set_can_manage_reusable_media(apps, schema_editor): + Team = apps.get_model('pretixbase', 'Team') + Team.objects.filter(can_change_organizer_settings=True).update(can_manage_reusable_media=True) + Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_reusable_media=True) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0235_auto_20230316_2023'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='media_policy', + field=models.CharField(max_length=16, null=True), + ), + migrations.AddField( + model_name='item', + name='media_type', + field=models.CharField(max_length=100, null=True), + ), + migrations.AddField( + model_name='team', + name='can_manage_reusable_media', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='ReusableMedium', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('type', models.CharField(max_length=100)), + ('identifier', models.CharField(max_length=200)), + ('active', models.BooleanField(default=True)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('info', models.JSONField(default=dict)), + ('notes', models.TextField(null=True, blank=True)), + ('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='reusable_media', to='pretixbase.customer')), + ('linked_giftcard', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media', + to='pretixbase.giftcard')), + ('linked_orderposition', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media', + to='pretixbase.orderposition')), + ('organizer', + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reusable_media', + to='pretixbase.organizer')), + ], + options={ + 'ordering': ('identifier', 'type', 'organizer'), + 'unique_together': {('identifier', 'type', 'organizer')}, + 'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')}, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AddField( + model_name='checkin', + name='raw_source_type', + field=models.CharField(max_length=100, null=True), + ), + migrations.RunPython( + set_can_manage_reusable_media, + migrations.RunPython.noop, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 5f72e624d7..ea5b8d0252 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -40,6 +40,7 @@ from .items import ( SubEventItem, SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry +from .media import ReusableMedium from .memberships import Membership, MembershipType from .notifications import NotificationSetting from .orders import ( diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 4a90dcd3a1..8651e3020c 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -44,6 +44,7 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes import ScopedManager, scopes_disabled +from pretix.base.media import MEDIA_TYPES from pretix.base.models import LoggedModel from pretix.base.models.fields import MultiStringField from pretix.helpers import PostgresWindowFrame @@ -377,6 +378,11 @@ class Checkin(models.Model): # For "raw" scans where we do not know which position they belong to (e.g. scan of signed # barcode that is not in database). raw_barcode = models.TextField(null=True, blank=True) + raw_source_type = models.CharField( + max_length=100, + null=True, blank=True, + choices=[(k, v) for k, v in MEDIA_TYPES.items()], + ) raw_item = models.ForeignKey( 'pretixbase.Item', related_name='checkins', diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py index bf2d740127..373dac8f3c 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -142,6 +142,7 @@ class Customer(LoggedModel): self.save() self.all_logentries().update(data={}, shredded=True) self.orders.all().update(customer=None) + self.reusable_media.all().update(customer=None) self.memberships.all().update(attendee_name_parts=None) self.attendee_profiles.all().delete() self.invoice_addresses.all().delete() diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index 1eb335ad86..158a78675d 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -180,7 +180,8 @@ class Device(LoggedModel): 'can_view_orders', 'can_change_orders', 'can_view_vouchers', - 'can_manage_gift_cards' + 'can_manage_gift_cards', + 'can_manage_reusable_media', } def get_event_permission_set(self, organizer, event) -> set: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b541e38076..1d376173e3 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -64,6 +64,7 @@ from pretix.base.models.fields import MultiStringField from pretix.base.models.tax import TaxedPrice from ...helpers.images import ImageSizeValidator +from ..media import MEDIA_TYPES from .event import Event, SubEvent @@ -368,6 +369,16 @@ class Item(LoggedModel): (VALIDITY_MODE_DYNAMIC, _('Dynamic validity')), ) + MEDIA_POLICY_REUSE = 'reuse' + MEDIA_POLICY_NEW = 'new' + MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' + MEDIA_POLICIES = ( + (None, _("Don't use re-usable media, use regular one-off tickets")), + (MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')), + (MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')), + (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')), + ) + objects = ItemQuerySetManager() event = models.ForeignKey( @@ -630,6 +641,29 @@ class Item(LoggedModel): help_text=_('The selected start date may only be this many days in the future.') ) + media_policy = models.CharField( + choices=MEDIA_POLICIES, + null=True, blank=True, max_length=16, + verbose_name=_('Reusable media policy'), + help_text=_( + 'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. ' + 'This is not required for regular tickets, which just use a one-time barcode, but only for products like ' + 'renewable season tickets or re-chargable gift card wristbands. ' + 'This is an advanced feature that also requires specific configuration of ticketing and printing settings.' + ) + ) + media_type = models.CharField( + max_length=100, + null=True, blank=True, + choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], + verbose_name=_('Reusable media type'), + help_text=_( + 'Select the type of physical medium that should be used for this product. Note that not all media types ' + 'support all types of products, and not all media types are supported across all sales channels or ' + 'check-in processes.' + ) + ) + # !!! Attention: If you add new fields here, also add them to the copying code in # pretix/control/forms/item.py if applicable. @@ -801,6 +835,24 @@ class Item(LoggedModel): def has_variations(self): return self.variations.exists() + @staticmethod + def clean_media_settings(event, media_policy, media_type, issue_giftcard): + if media_policy: + if not media_type: + raise ValidationError(_('If you select a reusable media policy, you also need to select a reusable ' + 'media type.')) + mt = MEDIA_TYPES[media_type] + if not mt.is_active(event.organizer): + raise ValidationError(_('The selected media type is not enabled in your organizer settings.')) + if not mt.supports_orderposition and not issue_giftcard: + raise ValidationError(_('The selected media type does not support usage for tickets currently.')) + if not mt.supports_giftcard and issue_giftcard: + raise ValidationError(_('The selected media type does not support usage for gift cards currently.')) + if issue_giftcard: + raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, ' + 'gift cards for some reusable media types can be created or re-charged directly ' + 'at the POS.')) + @staticmethod def clean_per_order(min_per_order, max_per_order): if min_per_order is not None and max_per_order is not None: diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py new file mode 100644 index 0000000000..59eb386d88 --- /dev/null +++ b/src/pretix/base/models/media.py @@ -0,0 +1,125 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django.db import models +from django.db.models import Q +from django.utils.functional import cached_property +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _, pgettext_lazy +from django_scopes import ScopedManager + +from pretix.base.media import MEDIA_TYPES +from pretix.base.models import LoggedModel +from pretix.base.models.customers import Customer +from pretix.base.models.giftcards import GiftCard +from pretix.base.models.orders import OrderPosition +from pretix.base.models.organizer import Organizer + + +class ReusableMediumQuerySet(models.QuerySet): + + def active(self): + return self.filter( + Q(expires__isnull=True) | Q(expires__gte=now()), + active=True, + ) + + +class ReusableMediumQuerySetManager(ScopedManager(organizer='organizer').__class__): + def __init__(self): + super().__init__() + self._queryset_class = ReusableMediumQuerySet + + def active(self): + return self.get_queryset().active() + + +class ReusableMedium(LoggedModel): + id = models.BigAutoField(primary_key=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + organizer = models.ForeignKey( + Organizer, + related_name='reusable_media', + on_delete=models.PROTECT + ) + + type = models.CharField( + verbose_name=pgettext_lazy('reusable_medium', 'Media type'), + choices=((k, v) for k, v in MEDIA_TYPES.items()), + max_length=100, + ) + identifier = models.CharField( + max_length=200, + verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), + ) + + active = models.BooleanField( + verbose_name=_('Active'), + default=True + ) + expires = models.DateTimeField( + verbose_name=_('Expiration date'), + null=True, blank=True + ) + + customer = models.ForeignKey( + Customer, + null=True, blank=True, + related_name='reusable_media', + on_delete=models.SET_NULL, + verbose_name=_('Customer account'), + ) + linked_orderposition = models.ForeignKey( + OrderPosition, + null=True, blank=True, + related_name='linked_media', + on_delete=models.SET_NULL, + verbose_name=_('Linked ticket'), + ) + linked_giftcard = models.ForeignKey( + GiftCard, + null=True, blank=True, + related_name='linked_media', + on_delete=models.SET_NULL, + verbose_name=_('Linked gift card'), + ) + + info = models.JSONField( + default=dict + ) + notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True) + + objects = ReusableMediumQuerySetManager() + + @cached_property + def media_type(self): + return MEDIA_TYPES[self.type] + + @property + def is_expired(self): + return self.expires and self.expires > now() + + class Meta: + unique_together = (("identifier", "type", "organizer"),) + index_together = (("identifier", "type", "organizer"), ("updated", "id")) + ordering = "identifier", "type", "organizer" diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 2417577a91..446ce09063 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -236,6 +236,8 @@ class Team(LoggedModel): :type can_change_teams: bool :param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts. :type can_manage_customers: bool + :param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media. + :type can_manage_reusable_media: bool :param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account. :type can_change_organizer_settings: bool :param can_change_event_settings: If ``True``, the members can change the settings of the associated events. @@ -277,6 +279,10 @@ class Team(LoggedModel): default=False, verbose_name=_("Can manage customer accounts") ) + can_manage_reusable_media = models.BooleanField( + default=False, + verbose_name=_("Can manage reusable media") + ) can_manage_gift_cards = models.BooleanField( default=False, verbose_name=_("Can manage gift cards") diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index c8cbec9a1f..4df6e18203 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -455,6 +455,11 @@ DEFAULT_VARIABLES = OrderedDict(( "TIME_FORMAT" ) if op.valid_until else "" }), + ("medium_identifier", { + "label": _("Reusable Medium ID"), + "editor_sample": _("ABC1234DEF4567"), + "evaluate": lambda op, order, ev: op.linked_media.all()[0].identifier if op.linked_media.all() else "", + }), ("seat", { "label": _("Seat: Full name"), "editor_sample": _("Ground floor, Row 3, Seat 4"), @@ -761,6 +766,9 @@ class Renderer: else: content = self._get_text_content(op, order, o) + if len(content) == 0: + return + level = 'H' if len(content) > 32: level = 'M' diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index afc6cde066..a6e5fcc18a 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -52,6 +52,7 @@ from django_scopes import scopes_disabled from pretix.base.channels import get_all_sales_channels from pretix.base.i18n import language +from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat, SeatCategoryMapping, Voucher, @@ -199,6 +200,8 @@ error_messages = { 'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'), 'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'), + 'media_usage_not_implemented': gettext_lazy('The configuration of this product requires mapping to a physical ' + 'medium, which is currently not available online.'), } @@ -394,6 +397,13 @@ class CartManager: if not op.item.is_available() or (op.variation and not op.variation.is_available()): raise CartError(error_messages['unavailable']) + if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + mt = MEDIA_TYPES[op.item.media_type] + if not mt.medium_created_by_server: + raise CartError(error_messages['media_usage_not_implemented']) + elif op.item.media_policy == Item.MEDIA_POLICY_REUSE: + raise CartError(error_messages['media_usage_not_implemented']) + if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels): raise CartError(error_messages['unavailable']) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 131a2d67e6..588f1a4ee2 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -693,7 +693,7 @@ def _save_answers(op, answers, given_answers): def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, - raw_barcode=None, from_revoked_secret=False): + raw_barcode=None, raw_source_type=None, from_revoked_secret=False): """ Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is not valid at this time. @@ -870,6 +870,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, forced=force and (not entry_allowed or from_revoked_secret or force_used), force_sent=force, raw_barcode=raw_barcode, + raw_source_type=raw_source_type, ) op.order.log_action('pretix.event.checkin', data={ 'position': op.id, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index ea1094ed75..2972619eef 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -62,6 +62,7 @@ from pretix.api.models import OAuthApplication from pretix.base.channels import get_all_sales_channels from pretix.base.email import get_email_context from pretix.base.i18n import get_language_without_region, language +from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, @@ -2911,3 +2912,32 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs): for p in order.positions.all(): if p.item.grant_membership_type_id: create_membership(order.customer, p) + + +@receiver(order_placed, dispatch_uid="pretixbase_order_placed_media") +@receiver(order_changed, dispatch_uid="pretixbase_order_changed_media") +@transaction.atomic() +def signal_listener_issue_media(sender: Event, order: Order, **kwargs): + from pretix.base.models import ReusableMedium + + for p in order.positions.all(): + if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + mt = MEDIA_TYPES[p.item.media_type] + if mt.medium_created_by_server and not p.linked_media.exists(): + rm = ReusableMedium.objects.create( + organizer=sender.organizer, + type=p.item.media_type, + identifier=mt.generate_identifier(sender.organizer), + active=True, + customer=order.customer, + linked_orderposition=p, + ) + rm.log_action( + 'pretix.reusable_medium.created', + data={ + 'by_order': order.code, + 'linked_orderposition': p.pk, + 'active': True, + 'customer': order.customer_id, + } + ) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 956ff21805..f11c78b956 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -169,6 +169,84 @@ DEFAULTS = { "was not logged in during the purchase.") ) }, + 'reusable_media_active': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Activate re-usable media"), + help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media " + "such as wristbands or chip cards that may be re-used for different tickets or gift cards " + "later.") + ) + }, + 'reusable_media_type_barcode': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Active"), + ) + }, + 'reusable_media_type_barcode_identifier_length': { + 'default': 24, + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'serializer_kwargs': dict( + validators=[ + MinValueValidator(12), + MaxValueValidator(64), + ] + ), + 'form_kwargs': dict( + label=_('Length of barcodes'), + validators=[ + MinValueValidator(12), + MaxValueValidator(64), + ], + required=True, + widget=forms.NumberInput( + attrs={ + 'min': '12', + 'max': '64', + }, + ), + ) + }, + 'reusable_media_type_nfc_uid': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Active"), + ) + }, + 'reusable_media_type_nfc_uid_autocreate_giftcard': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Automatically create a new gift card if a previously unknown chip is seen"), + ) + }, + 'reusable_media_type_nfc_uid_autocreate_giftcard_currency': { + 'default': 'EUR', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES], + ), + 'form_kwargs': dict( + choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES], + label=_("Gift card currency"), + ) + }, 'max_items_per_order': { 'default': '10', 'type': int, @@ -3416,7 +3494,12 @@ def validate_organizer_settings(organizer, settings_dict): # organizer-settings either. # # N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered. - pass + """ + if settings_dict.get('reusable_media_type_ntag_pretix1') and settings_dict.get('reusable_media_type_nfc_uid'): + raise ValidationError({ + 'reusable_media_type_nfc_uid': _('This needs to be disabled if other NFC-based types are active.') + }) + """ def global_settings_object(holder): diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index c704a6a468..e660aac24b 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1419,6 +1419,62 @@ class CustomerFilterForm(FilterForm): return qs.distinct() +class ReusableMediaFilterForm(FilterForm): + orders = { + 'type': 'type', + 'identifier': 'identifier', + } + query = forms.CharField( + label=_('Search query'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Search query'), + 'autofocus': 'autofocus' + }), + required=False + ) + status = forms.ChoiceField( + label=_('Status'), + required=False, + choices=( + ('', _('All')), + ('active', _('active')), + ('disabled', _('disabled')), + ('expired', _('expired')), + ) + ) + + def __init__(self, *args, **kwargs): + kwargs.pop('request') + super().__init__(*args, **kwargs) + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('query'): + query = fdata.get('query') + qs = qs.filter( + Q(identifier__icontains=query) + | Q(customer__identifier__icontains=query) + | Q(customer__external_identifier__istartswith=query) + | Q(linked_orderposition__order__code__icontains=query) + | Q(linked_giftcard__secret__icontains=query) + ) + + if fdata.get('status') == 'active': + qs = qs.filter(Q(expires__gt=now()) | Q(expires__isnull=False), active=True) + elif fdata.get('status') == 'disabled': + qs = qs.filter(active=False) + elif fdata.get('status') == 'expired': + qs = qs.filter(expires__lte=now()) + + if fdata.get('ordering'): + qs = qs.order_by(self.get_order_by()) + else: + qs = qs.order_by("identifier", "type", "organizer") + + return qs.distinct() + + class TeamFilterForm(FilterForm): orders = { 'name': 'name', diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index de1eae9433..2fcdee094d 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -401,6 +401,8 @@ class ItemCreateForm(I18nModelForm): 'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit', + 'media_type', + 'media_policy', ) for f in fields: setattr(self.instance, f, getattr(src, f)) @@ -592,6 +594,10 @@ class ItemUpdateForm(I18nModelForm): del self.fields['grant_membership_duration_days'] del self.fields['grant_membership_duration_months'] + if not self.event.settings.reusable_media_active: + del self.fields['media_type'] + del self.fields['media_policy'] + def clean(self): d = super().clean() if d['issue_giftcard']: @@ -635,6 +641,8 @@ class ItemUpdateForm(I18nModelForm): _("The start of validity must be before the end of validity.") ) + Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard')) + return d def clean_picture(self): @@ -693,6 +701,8 @@ class ItemUpdateForm(I18nModelForm): 'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit', + 'media_policy', + 'media_type', ] field_classes = { 'available_from': SplitDateTimeField, diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 5c5b2ad0d3..f87b0a9185 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -41,10 +41,14 @@ from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import inlineformset_factory from django.forms.utils import ErrorDict +from django.urls import reverse from django.utils.crypto import get_random_string +from django.utils.html import conditional_escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy -from django_scopes.forms import SafeModelMultipleChoiceField +from django_scopes.forms import ( + SafeModelChoiceField, SafeModelMultipleChoiceField, +) from i18nfield.forms import ( I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, ) @@ -62,15 +66,18 @@ from pretix.base.forms.questions import ( from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import ( Customer, Device, EventMetaProperty, Gate, GiftCard, Membership, - MembershipType, Organizer, Team, + MembershipType, OrderPosition, Organizer, ReusableMedium, Team, ) from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.organizer import OrganizerFooterLink -from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS +from pretix.base.settings import ( + PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings, +) from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms.event import ( SafeEventMultipleChoiceField, multimail_validate, ) +from pretix.control.forms.widgets import Select2 from pretix.multidomain.models import KnownDomain from pretix.multidomain.urlreverse import build_absolute_uri @@ -208,6 +215,7 @@ class TeamForm(forms.ModelForm): fields = ['name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_manage_customers', + 'can_manage_reusable_media', 'can_change_event_settings', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers', 'can_change_vouchers'] @@ -389,6 +397,12 @@ class OrganizerSettingsForm(SettingsForm): 'cookie_consent_dialog_text_secondary', 'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_no', + 'reusable_media_active', + 'reusable_media_type_barcode', + 'reusable_media_type_barcode_identifier_length', + 'reusable_media_type_nfc_uid', + 'reusable_media_type_nfc_uid_autocreate_giftcard', + 'reusable_media_type_nfc_uid_autocreate_giftcard_currency', ] organizer_logo_image = ExtFileField( @@ -431,6 +445,26 @@ class OrganizerSettingsForm(SettingsForm): )) for k, v in PERSON_NAME_TITLE_GROUPS.items() ] + self.fields['reusable_media_active'].label = mark_safe( + conditional_escape(self.fields['reusable_media_active'].label) + + ' ' + + '{}'.format(_('experimental')) + ) + self.fields['reusable_media_active'].help_text = mark_safe( + conditional_escape(self.fields['reusable_media_active'].help_text) + + ' ' + + '
' + + _('This feature is currently in an experimental stage. It only supports very limited use cases and might ' + 'change at any point.') + ) + + def clean(self): + data = super().clean() + settings_dict = self.obj.settings.freeze() + settings_dict.update(data) + + validate_organizer_settings(self.obj, data) + return data class MailSettingsForm(SettingsForm): @@ -626,6 +660,116 @@ class GiftCardUpdateForm(forms.ModelForm): } +class SafeOrderPositionChoiceField(forms.ModelChoiceField): + def __init__(self, queryset, **kwargs): + queryset = queryset.model.all.none() + super().__init__(queryset, **kwargs) + + def label_from_instance(self, op): + return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' + + +class ReusableMediumUpdateForm(forms.ModelForm): + error_messages = { + 'duplicate': _("An medium with this type and identifier is already registered."), + } + + class Meta: + model = ReusableMedium + fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes'] + field_classes = { + 'expires': SplitDateTimeField, + 'customer': SafeModelChoiceField, + 'linked_giftcard': SafeModelChoiceField, + 'linked_orderposition': SafeOrderPositionChoiceField, + } + widgets = { + 'expires': SplitDateTimePickerWidget, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + organizer = self.instance.organizer + + self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() + self.fields['linked_orderposition'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={ + 'organizer': organizer.slug, + }), + 'data-placeholder': _('Ticket') + } + ) + self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices + self.fields['linked_orderposition'].required = False + + self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all() + self.fields['linked_giftcard'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={ + 'organizer': organizer.slug, + }), + 'data-placeholder': _('Gift card') + } + ) + self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices + self.fields['linked_giftcard'].required = False + + if organizer.settings.customer_accounts: + self.fields['customer'].queryset = organizer.customers.all() + self.fields['customer'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.customers.select2', kwargs={ + 'organizer': organizer.slug, + }), + 'data-placeholder': _('Customer') + } + ) + self.fields['customer'].widget.choices = self.fields['customer'].choices + self.fields['customer'].required = False + else: + del self.fields['customer'] + + def clean(self): + identifier = self.cleaned_data.get('identifier') + type = self.cleaned_data.get('type') + + if identifier is not None and type is not None: + try: + self.instance.organizer.reusable_media.exclude(pk=self.instance.pk).get( + identifier=identifier, + type=type, + ) + except ReusableMedium.DoesNotExist: + pass + else: + raise forms.ValidationError( + self.error_messages['duplicate'], + code='duplicate', + ) + + return self.cleaned_data + + +class ReusableMediumCreateForm(ReusableMediumUpdateForm): + + class Meta: + model = ReusableMedium + fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes'] + field_classes = { + 'expires': SplitDateTimeField, + 'customer': SafeModelChoiceField, + 'linked_giftcard': SafeModelChoiceField, + 'linked_orderposition': SafeOrderPositionChoiceField, + } + widgets = { + 'expires': SplitDateTimePickerWidget, + } + + class CustomerUpdateForm(forms.ModelForm): error_messages = { 'duplicate': _("An account with this email address is already registered."), diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index d8919144bb..737c8c0c15 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -362,6 +362,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.customer.anonymized': _('The account has been disabled and anonymized.'), 'pretix.customer.password.resetrequested': _('A new password has been requested.'), 'pretix.customer.password.set': _('A new password has been set.'), + 'pretix.reusable_medium.created': _('The reusable medium has been created.'), + 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), + 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), + 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), 'pretix.email.error': _('Sending of an email has failed.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'), 'pretix.event.canceled': _('The event has been canceled.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index b40fab0f8d..89ec4ee3a1 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -578,6 +578,16 @@ def get_organizer_navigation(request): 'children': children, }) + if request.organizer.settings.reusable_media_active: + nav.append({ + 'label': _('Reusable media'), + 'url': reverse('control:organizer.reusable_media', kwargs={ + 'organizer': request.organizer.slug + }), + 'icon': 'key', + 'active': 'organizer.reusable_medi' in url.url_name, + }) + if 'can_change_organizer_settings' in request.orgapermset: nav.append({ 'label': _('Devices'), diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 8879bff283..20a9844548 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -4,6 +4,7 @@ {% load formset_tags %} {% block inside %}
+ {% bootstrap_form_errors form layout="control" %} {% csrf_token %}
@@ -178,6 +179,12 @@
{% trans "Tickets & Badges" %} {% bootstrap_field form.generate_tickets layout="control" %} + {% if form.media_policy %} + {% bootstrap_field form.media_policy layout="control" %} + {% endif %} + {% if form.media_type %} + {% bootstrap_field form.media_type layout="control" %} + {% endif %} {% for f in plugin_forms %} {% if f.is_layouts %} {% bootstrap_form f layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index bfc87ebef1..8648279b0b 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -462,6 +462,14 @@
{% endif %} + {% for m in line.linked_media.all %} +
+
+ + {{ m.identifier }} ({{ m.get_type_display }}) +
+
+ {% endfor %} {% if not line.canceled %}
{% if line.generate_ticket %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index f021e3fde8..52628f572a 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -200,6 +200,70 @@ {% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %} {% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %} +
+ {% trans "Reusable media" %} + {% bootstrap_field sform.reusable_media_active layout="control" %} +
+ +
+
+

{% trans "Barcode media" %}

+
+
+

+ {% blocktrans trimmed %} + A "barcode medium" can be any printed or digital representation of a barcode. + The medium will initially be created through the sale of a product that has a + media policy requiring such a medium as well as a ticket or badge layout that + includes the "Reusable Medium ID" as a QR code. Later, the same barcode may + be re-used during the sale of a different product. + {% endblocktrans %} + {% blocktrans trimmed %} + Barcode media can currently only be connected to tickets. + {% endblocktrans %} + {% blocktrans trimmed %} + This subsequent reuse of the barcode is currently only supported during POS sales. + {% endblocktrans %} +

+ {% bootstrap_field sform.reusable_media_type_barcode layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_barcode_identifier_length layout="control" %} +
+
+
+ +
+
+

{% trans "NFC UID-based" %}

+
+
+

+ {% blocktrans trimmed %} + This medium type can work with almost any type of NFC chip. With this + option, only the UID of the NFC chip is used for identification. + {% endblocktrans %} + {% blocktrans trimmed %} + NFC media can currently only be connected to gift cards. + {% endblocktrans %} +

+

+ + {% blocktrans trimmed %} + This method does not provide a high level of protection against abuse since it + is possible for malicious users to clone someone's chip with the same UID. + {% endblocktrans %} +

+ {% bootstrap_field sform.reusable_media_type_nfc_uid layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard layout="control" %} +
+ {% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard_currency layout="control" %} +
+
+
+
+
+
{% trans "Invoices" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html new file mode 100644 index 0000000000..f481e01cf4 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html @@ -0,0 +1,113 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load urlreplace %} +{% load bootstrap3 %} +{% load money %} +{% block title %}{% trans "Reusable media" %}{% endblock %} +{% block inner %} +

+ {% trans "Reusable media" %} +

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

+ {% blocktrans trimmed %} + No media have been created yet. + {% endblocktrans %} +

+ {% trans "Create a new medium" %} +
+ {% else %} +
+
+

{% trans "Filter" %}

+
+ +
+
+ {% bootstrap_field filter_form.query layout='inline' %} +
+
+ {% bootstrap_field filter_form.status layout='inline' %} +
+
+
+ +
+ +
+

+ {% trans "Create a new medium" %} +

+
+ + + + + + + + + + + {% for m in media %} + + + + + + + {% endfor %} + +
{% trans "Identifier" context "reusable_media" %} + + + {% trans "Media type" context "reusable_media" %} + + {% trans "Connections" context "reusable_media" %}
+ + {% if not m.active %}{% endif %} + {{ m.identifier }} + {% if not m.active %}{% endif %} + + + {{ m.get_type_display }} + + {% if m.customer %} + + + + {{ m.customer }} + + + {% endif %} + {% if m.linked_orderposition %} + + + + {{ m.linked_orderposition.order.code }}-{{ m.linked_orderposition.positionid }} + + {% endif %} + {% if m.linked_giftcard %} + + + + {{ m.linked_giftcard.secret }} + + {% endif %} + + + + +
+
+ {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html new file mode 100644 index 0000000000..66ea2d2804 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html @@ -0,0 +1,93 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load money %} +{% block title %} + {% blocktrans trimmed with id=medium.identifier context "reusable_media" %} + Medium {{ id }} + {% endblocktrans %} +{% endblock %} +{% block inner %} +

+ {% blocktrans trimmed with id=medium.identifier context "reusable_media" %} + Medium {{ id }} + {% endblocktrans %} +

+
+
+
+
+

+ {% trans "Details" %} +

+
+
+
+ {% csrf_token %} +
+
{% trans "Media type" context "reusable_media" %}
+
{{ medium.get_type_display }}
+
{% trans "Identifier" context "reusable_media" %}
+
{{ medium.identifier }}
+
{% trans "Status" %}
+
+ {% if not medium.active %} + {% trans "disabled" %} + {% elif medium.is_expired %} + {% trans "expired" %} + {% else %} + {% trans "active" %} + {% endif %} +
+
{% trans "Connections" context "reusable_media" %}
+
+ {% if medium.customer %} + + + + {{ medium.customer }} + + + {% endif %} + {% if medium.linked_orderposition %} + + + + {{ medium.linked_orderposition.order.code }}-{{ medium.linked_orderposition.positionid }} + + {% endif %} + {% if medium.linked_giftcard %} + + + + {{ medium.linked_giftcard.secret }} + + {% endif %} +
+ {% if medium.notes %} +
{% trans "Notes" %}
+
{{ medium.notes }}
+ {% endif %} +
+
+ +
+
+
+
+
+
+

+ {% trans "Medium history" context "reusable_media" %} +

+
+ {% include "pretixcontrol/includes/logs.html" with obj=medium %} +
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium_edit.html new file mode 100644 index 0000000000..3c940383ef --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium_edit.html @@ -0,0 +1,32 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% if not medium.pk %} + {% trans "New medium" context "reusable_media" %} + {% else %} + {% blocktrans trimmed with id=medium.identifier context "reusable_media" %} + Medium {{ id }} + {% endblocktrans %} + {% endif %} +{% endblock %} +{% block inner %} +

+ {% if not medium.pk %} + {% trans "New medium" context "reusable_media" %} + {% else %} + {% blocktrans trimmed with id=medium.identifier context "reusable_media" %} + Medium {{ id }} + {% endblocktrans %} + {% endif %} +

+
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html index 601735c9ea..70217e03eb 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html @@ -24,6 +24,7 @@ {% bootstrap_field form.can_create_events layout="control" %} {% bootstrap_field form.can_manage_gift_cards layout="control" %} {% bootstrap_field form.can_manage_customers layout="control" %} + {% bootstrap_field form.can_manage_reusable_media layout="control" %} {% bootstrap_field form.can_change_teams layout="control" %} {% bootstrap_field form.can_change_organizer_settings layout="control" %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 4b77593fc2..e4e95546c0 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -164,7 +164,15 @@ urlpatterns = [ organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'), re_path(r'^organizer/(?P[^/]+)/customer/(?P[^/]+)/anonymize$', organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'), + re_path(r'^organizer/(?P[^/]+)/reusable_media$', organizer.ReusableMediaListView.as_view(), name='organizer.reusable_media'), + re_path(r'^organizer/(?P[^/]+)/reusable_media/add$', + organizer.ReusableMediumCreateView.as_view(), name='organizer.reusable_medium.create'), + re_path(r'^organizer/(?P[^/]+)/reusable_media/(?P[^/]+)/$', + organizer.ReusableMediumDetailView.as_view(), name='organizer.reusable_medium'), + re_path(r'^organizer/(?P[^/]+)/reusable_media/(?P[^/]+)/edit$', + organizer.ReusableMediumUpdateView.as_view(), name='organizer.reusable_medium.edit'), re_path(r'^organizer/(?P[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), + re_path(r'^organizer/(?P[^/]+)/giftcards/select2$', typeahead.giftcard_select2, name='organizer.giftcards.select2'), re_path(r'^organizer/(?P[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'), 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(), @@ -213,6 +221,7 @@ urlpatterns = [ name='organizer.export.scheduled.run'), re_path(r'^organizer/(?P[^/]+)/export/(?P[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(), name='organizer.export.scheduled.delete'), + re_path(r'^organizer/(?P[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'), re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), re_path(r'^events/$', main.EventList.as_view(), name='events'), re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index d01109b599..9fc7d4a60c 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -367,7 +367,7 @@ class OrderDetail(OrderView): 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type', 'discount', ).prefetch_related( - 'item__questions', 'issued_gift_cards', + 'item__questions', 'issued_gift_cards', 'linked_media', Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')), ).order_by('positionid') diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 20a6ffdb2e..7ed3734f6b 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -73,7 +73,7 @@ from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry, Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer, - ScheduledOrganizerExport, Team, TeamInvite, User, + ReusableMedium, ScheduledOrganizerExport, Team, TeamInvite, User, ) from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue @@ -92,7 +92,7 @@ from pretix.base.views.tasks import AsyncAction from pretix.control.forms.exports import ScheduledOrganizerExportForm from pretix.control.forms.filter import ( CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm, - OrganizerFilterForm, TeamFilterForm, + OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm, ) from pretix.control.forms.orders import ExporterForm from pretix.control.forms.organizer import ( @@ -100,8 +100,9 @@ from pretix.control.forms.organizer import ( EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm, - TeamForm, WebHookForm, + OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm, + ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm, + WebHookForm, ) from pretix.control.forms.rrule import RRuleForm from pretix.control.logdisplay import OVERVIEW_BANLIST @@ -533,7 +534,7 @@ class OrganizerCreate(CreateView): organizer=form.instance, name=_('Administrators'), all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True, can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True, - can_manage_customers=True, + can_manage_customers=True, can_manage_reusable_media=True, can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True ) t.members.add(self.request.user) @@ -2738,3 +2739,101 @@ class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequire 'organizer': self.request.organizer.slug, 'customer': self.object.identifier, }) + + +class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView): + model = ReusableMedium + template_name = 'pretixcontrol/organizers/reusable_media.html' + permission = 'can_manage_reusable_media' + context_object_name = 'media' + + def get_queryset(self): + qs = self.request.organizer.reusable_media.select_related( + 'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event', + 'linked_giftcard' + ) + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['filter_form'] = self.filter_form + return ctx + + @cached_property + def filter_form(self): + return ReusableMediaFilterForm(data=self.request.GET, request=self.request) + + +class ReusableMediumDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView): + template_name = 'pretixcontrol/organizers/reusable_medium.html' + permission = 'can_manage_reusable_media' + + @cached_property + def medium(self): + return get_object_or_404( + self.request.organizer.reusable_media, + pk=self.kwargs.get('pk') + ) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['medium'] = self.medium + return ctx + + +class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + template_name = 'pretixcontrol/organizers/reusable_medium_edit.html' + permission = 'can_manage_reusable_media' + context_object_name = 'medium' + form_class = ReusableMediumCreateForm + + def get_form_kwargs(self): + ctx = super().get_form_kwargs() + c = ReusableMedium(organizer=self.request.organizer) + ctx['instance'] = c + return ctx + + def form_valid(self, form): + r = super().form_valid(form) + form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={ + k: getattr(form.instance, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return r + + def get_success_url(self): + return reverse('control:organizer.reusable_medium', kwargs={ + 'organizer': self.request.organizer.slug, + 'pk': self.object.pk, + }) + + +class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView): + template_name = 'pretixcontrol/organizers/reusable_medium_edit.html' + permission = 'can_manage_reusable_media' + context_object_name = 'medium' + form_class = ReusableMediumUpdateForm + + def get_object(self, queryset=None): + return get_object_or_404( + self.request.organizer.reusable_media, + pk=self.kwargs.get('pk') + ) + + def form_valid(self, form): + if form.has_changed(): + self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={ + k: getattr(self.object, k) + for k in form.changed_data + }) + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def get_success_url(self): + return reverse('control:organizer.reusable_medium', kwargs={ + 'organizer': self.request.organizer.slug, + 'pk': self.object.pk, + }) diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 6dca1c7f75..091c1b8808 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -48,8 +48,8 @@ from django.utils.translation import gettext as _, pgettext from pretix.base.models import ( EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, - ItemVariation, ItemVariationMetaValue, Order, Organizer, SubEventMetaValue, - User, Voucher, + ItemVariation, ItemVariationMetaValue, Order, OrderPosition, Organizer, + SubEventMetaValue, User, Voucher, ) from pretix.control.forms.event import EventWizardCopyForm from pretix.control.permissions import ( @@ -172,6 +172,104 @@ def event_list(request): return JsonResponse(doc) +@organizer_permission_required(("can_manage_gift_cards", "can_manage_reusable_media")) +def giftcard_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + if request.user.has_organizer_permission(request.organizer, 'can_manage_gift_cards', request): + qs = request.organizer.issued_gift_cards.filter( + Q(secret__icontains=query) + ).order_by('secret') + else: + qs = request.organizer.issued_gift_cards.filter( + Q(secret__iexact=query) + ).order_by('secret') + + if not query: + qs = qs.none() + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': e.pk, + 'text': str(e), + } + for e in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + +@organizer_permission_required(("can_manage_reusable_media")) +def ticket_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + qs_orders = OrderPosition.all.select_related('order', 'order__event', 'item', 'variation').filter( + order__event__organizer=request.organizer, + ).order_by() + + exact_match = Q(secret__iexact=query) + soft_match = Q(secret__icontains=query) + + qsplit = query.split("-") + + if len(qsplit) >= 3 and qsplit[2].isdigit(): + soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__iexact=qsplit[1], positionid=qsplit[2]) + elif len(qsplit) >= 2 and qsplit[1].isdigit(): + soft_match |= Q(order__code__istartswith=qsplit[0], positionid=qsplit[1]) + elif len(qsplit) >= 2: + soft_match |= Q(order__event__slug__iexact=qsplit[0], order__code__istartswith=qsplit[1]) + else: + soft_match |= Q(order__code__istartswith=qsplit[0]) + + if not request.user.has_active_staff_session(request.session.session_key): + qs_orders = qs_orders.filter( + exact_match | ( + soft_match & ( + Q(order__event__organizer_id__in=request.user.teams.filter(all_events=True, can_view_orders=True).values_list('organizer', flat=True)) + | Q(order__event_id__in=request.user.teams.filter(can_view_orders=True).values_list('limit_events__id', flat=True)) + ) + ) + ) + else: + qs_orders = qs_orders.filter(exact_match | soft_match) + + if not query: + qs_orders = qs_orders.none() + + total = qs_orders.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': op.pk, + 'text': f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})', + 'event': str(op.order.event) + } + for op in qs_orders[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + @organizer_permission_required("can_manage_customers") def customer_select2(request, **kwargs): query = request.GET.get('query', '') diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 860d77c3d1..cee074e98d 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -116,6 +116,7 @@ def team(organizer): can_view_vouchers=True, can_change_orders=True, can_manage_customers=True, + can_manage_reusable_media=True, can_change_organizer_settings=True ) diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 922ddccea7..40a7d53b9b 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -33,7 +33,9 @@ from pytz import UTC from tests.const import SAMPLE_PNG from pretix.api.serializers.item import QuestionSerializer -from pretix.base.models import Checkin, InvoiceAddress, Order, OrderPosition +from pretix.base.models import ( + Checkin, InvoiceAddress, Order, OrderPosition, ReusableMedium, +) # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering # the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take @@ -275,6 +277,72 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order): assert resp.data['status'] == 'ok' +@pytest.mark.django_db +def test_by_medium(token_client, organizer, clist, event, order): + with scopes_disabled(): + ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + linked_orderposition=order.positions.first(), + ) + resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + ci = clist.checkins.get(position=order.positions.first()) + assert ci.raw_barcode == "abcdef" + assert ci.raw_source_type == "barcode" + + +@pytest.mark.django_db +def test_by_medium_not_connected(token_client, organizer, clist, event, order): + with scopes_disabled(): + ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + ) + resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 404 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'invalid' + + +@pytest.mark.django_db +def test_by_medium_wrong_type(token_client, organizer, clist, event, order): + with scopes_disabled(): + ReusableMedium.objects.create( + type="nfc_uid", + identifier="abcdef", + organizer=organizer, + linked_orderposition=order.positions.first(), + ) + resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 404 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'invalid' + resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "nfc_uid"}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_by_medium_inactive(token_client, organizer, clist, event, order): + with scopes_disabled(): + ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + active=False, + linked_orderposition=order.positions.first(), + ) + resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 404 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'invalid' + + @pytest.mark.django_db def test_only_once(token_client, organizer, clist, event, order): with scopes_disabled(): diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 4b61be8a99..6c0bcd0cf5 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -1212,7 +1212,8 @@ def test_get_event_settings(token_client, organizer, event): "value": "https://example.org", "label": "Imprint URL", "help_text": "This should point e.g. to a part of your website that has your contact details and legal " - "information." + "information.", + "readonly": False, } @@ -1229,14 +1230,17 @@ def test_patch_event_settings(token_client, organizer, event): { 'de': 'Ich bin mit den AGB einverstanden.' } - ] + ], + 'reusable_media_active': True, # readonly, ignored }, format='json' ) assert resp.status_code == 200 assert resp.data['imprint_url'] == "https://example.com" + assert not resp.data['reusable_media_active'] event.settings.flush() assert event.settings.imprint_url == 'https://example.com' + assert not event.settings.reusable_media_active mocked.assert_not_called() resp = token_client.patch( diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index fea58f4dac..725edb31a4 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -296,6 +296,8 @@ TEST_ITEM_RES = { "grant_membership_duration_like_event": True, "grant_membership_duration_days": 0, "grant_membership_duration_months": 0, + "media_policy": None, + "media_type": None, "validity_mode": None, "validity_fixed_from": None, "validity_fixed_until": None, diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 16218aa63e..60dffd61cb 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -35,7 +35,8 @@ from pytz import UTC from tests.const import SAMPLE_PNG from pretix.base.models import ( - InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, + InvoiceAddress, Item, Order, OrderPosition, Organizer, Question, + SeatingPlan, ) from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer @@ -55,6 +56,27 @@ def taxrule(event): return event.tax_rules.create(rate=Decimal('19.00')) +@pytest.fixture +def medium(organizer): + return organizer.reusable_media.create( + type="barcode", + identifier="ABCDE" + ) + + +@pytest.fixture +def organizer2(): + return Organizer.objects.create(name='Partner', slug='partner') + + +@pytest.fixture +def medium2(organizer2): + return organizer2.reusable_media.create( + type="barcode", + identifier="ABCDE" + ) + + @pytest.fixture def question(event, item): q = event.questions.create(question="T-Shirt size", type="S", identifier="ABC") @@ -2810,3 +2832,72 @@ def test_create_cart_and_consume_cart_with_addons(token_client, organizer, event ), format='json', data=res ) assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_create_use_medium(token_client, organizer, event, item, quota, question, medium): + item.media_type = medium.type + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['use_reusable_medium'] = medium.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + medium.refresh_from_db() + assert o.positions.first() == medium.linked_orderposition + assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier + + +@pytest.mark.django_db +def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2): + item.media_type = medium2.type + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['use_reusable_medium'] = medium2.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.data == { + "positions": [ + { + "use_reusable_medium": ["The specified medium does not belong to this organizer."] + } + ] + } + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_order_create_create_medium(token_client, organizer, event, item, quota, question): + item.media_type = 'barcode' + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + i = resp.data['positions'][0]['pdf_data']['medium_identifier'] + assert i + m = organizer.reusable_media.get(identifier=i) + assert m.linked_orderposition == o.positions.first() + assert m.type == "barcode" diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index f73749db55..a991aa21ef 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -1802,7 +1802,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q assert not resp.data['positions'][0].get('pdf_data') # order list - with django_assert_max_num_queries(29): + with django_assert_max_num_queries(30): resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( organizer.slug, event.slug )) @@ -1815,7 +1815,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q assert not resp.data['results'][0]['positions'][0].get('pdf_data') # position list - with django_assert_max_num_queries(32): + with django_assert_max_num_queries(33): resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format( organizer.slug, event.slug )) diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py index 4bc7fec36f..40ee35e255 100644 --- a/src/tests/api/test_organizers.py +++ b/src/tests/api/test_organizers.py @@ -62,7 +62,8 @@ def test_get_settings(token_client, organizer): assert resp.data['event_list_type'] == { "value": "week", "label": "Default overview style", - "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used." + "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used.", + "readonly": False, } diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py new file mode 100644 index 0000000000..5510f0b8d6 --- /dev/null +++ b/src/tests/api/test_reusable_media.py @@ -0,0 +1,352 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# 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 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 + + +@pytest.fixture +def giftcard(organizer): + gc = organizer.issued_gift_cards.create(secret="ABCDEF", currency="EUR") + gc.transactions.create(value=Decimal('23.00')) + return gc + + +@pytest.fixture +def medium(organizer): + m = organizer.reusable_media.create(identifier="ABCDEFGH", type="barcode", active=True) + return m + + +@pytest.fixture +def organizer2(): + return Organizer.objects.create(name='Partner', slug='partner') + + +@pytest.fixture +def giftcard2(organizer2): + gc = organizer2.issued_gift_cards.create(secret="ABCDEF", currency="EUR") + gc.transactions.create(value=Decimal('23.00')) + return gc + + +@pytest.fixture +def customer(organizer, event): + return organizer.customers.create( + identifier="8WSAJCJ", + email="foo@example.org", + name_parts={"_legacy": "Foo"}, + name_cached="Foo", + is_verified=False, + ) + + +TEST_MEDIUM_RES = { + "id": 1, + "identifier": "ABCDEFGH", + "type": "barcode", + "active": True, + "expires": None, + "customer": None, + "linked_orderposition": None, + "linked_giftcard": None, + "notes": None, + "info": {}, +} + + +@pytest.mark.django_db +def test_medium_list(token_client, organizer, event, medium): + res = dict(TEST_MEDIUM_RES) + res["id"] = medium.pk + res["created"] = medium.created.isoformat().replace('+00:00', 'Z') + res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z') + + resp = token_client.get('/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/reusablemedia/?identifier=XYZABC'.format(organizer.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/reusablemedia/?identifier=ABCDEFGH'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_medium_detail(token_client, organizer, event, medium, giftcard, customer): + res = dict(TEST_MEDIUM_RES) + res["id"] = medium.pk + res["created"] = medium.created.isoformat().replace('+00:00', 'Z') + res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z') + resp = token_client.get('/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk)) + assert resp.status_code == 200 + assert res == resp.data + + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + total=14, locale='en' + ) + ticket = 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")) + medium.linked_orderposition = op + medium.linked_giftcard = giftcard + medium.customer = customer + medium.save() + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=linked_orderposition&expand=customer'.format( + organizer.slug, medium.pk)) + assert resp.status_code == 200 + + assert resp.data["customer"] == { + "identifier": customer.identifier, + "external_identifier": None, + "email": "foo@example.org", + "name": "Foo", + "name_parts": {"_legacy": "Foo"}, + "is_active": True, + "is_verified": False, + "last_login": None, + "date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"), + "locale": "en", + "last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"), + "notes": None + } + assert resp.data["linked_orderposition"] == { + "id": op.pk, + "order": {"code": "FOO", "event": "dummy"}, + "positionid": op.positionid, + "item": ticket.pk, + "variation": None, + "price": "14.00", + "attendee_name": None, + "attendee_name_parts": {}, + "company": None, + "street": None, + "zipcode": None, + "city": None, + "country": None, + "state": None, + "discount": None, + "attendee_email": None, + "voucher": None, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": op.secret, + "addon_to": None, + "subevent": None, + "checkins": [], + "downloads": [], + "answers": [], + "tax_rule": None, + "pseudonymization_id": op.pseudonymization_id, + "pdf_data": {}, + "seat": None, + "canceled": False, + "valid_from": None, + "valid_until": None, + "blocked": None + } + assert resp.data["linked_giftcard"] == { + "id": giftcard.pk, + "secret": "ABCDEF", + "issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"), + "value": "23.00", + "currency": "EUR", + "testmode": False, + "expires": None, + "conditions": None + } + + +TEST_MEDIUM_CREATE_PAYLOAD = { + "type": "barcode", + "identifier": "FOOBAR", + "active": True, + "info": {"foo": "bar"} +} + + +@pytest.mark.django_db +def test_medium_create(token_client, organizer, giftcard): + payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) + payload['linked_giftcard'] = giftcard.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=resp.data['id']) + assert m.organizer == organizer + assert m.type == "barcode" + assert m.identifier == "FOOBAR" + assert m.active + assert m.linked_giftcard == giftcard + assert m.info == {"foo": "bar"} + assert m.created > now() - timedelta(minutes=10) + assert m.updated > now() - timedelta(minutes=10) + + +@pytest.mark.django_db +def test_medium_foreignkeyval(token_client, organizer, giftcard2): + payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) + payload['linked_giftcard'] = giftcard2.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == {'linked_giftcard': [f'Invalid pk "{giftcard2.pk}" - object does not exist.']} + + +@pytest.mark.django_db +def test_medium_create_duplicate(token_client, organizer, event, medium): + payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) + payload['identifier'] = medium.identifier + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'identifier': ['A medium with the same identifier and type already exists in your organizer account.']} + + +@pytest.mark.django_db +def test_medium_patch(token_client, organizer, event, medium, giftcard, customer): + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_giftcard': giftcard.pk, + 'customer': customer.identifier, + 'info': {'test': 2}, + 'identifier': 'WILLBEIGNORED', + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + assert medium.linked_giftcard == giftcard + assert medium.customer == customer + assert medium.info == {'test': 2} + assert medium.identifier == "ABCDEFGH" + + +@pytest.mark.django_db +def test_medium_no_deletion(token_client, organizer, event, medium): + resp = token_client.delete( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_medium_lookup_ok(token_client, organizer, event, medium): + res = dict(TEST_MEDIUM_RES) + res["id"] = medium.pk + res["created"] = medium.created.isoformat().replace('+00:00', 'Z') + res["updated"] = medium.updated.isoformat().replace('+00:00', 'Z') + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug), + { + "type": medium.type, + "identifier": medium.identifier, + }, + format='json' + ) + assert resp.status_code == 200 + assert res == resp.data["result"] + + +@pytest.mark.django_db +def test_medium_lookup_not_found(token_client, organizer, organizer2, medium): + medium.organizer = organizer2 + medium.save() + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug), + { + "type": medium.type, + "identifier": medium.identifier, + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data["result"] is None + + +@pytest.mark.django_db +def test_medium_autocreate(token_client, organizer): + # Disabled + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug), + { + "type": "nfc_uid", + "identifier": "AABBCCDD", + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data["result"] is None + + # Enabled + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard = True + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard_currency = 'EUR' + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/lookup/?expand=linked_giftcard'.format(organizer.slug), + { + "type": "nfc_uid", + "identifier": "AABBCCDD", + }, + format='json' + ) + assert resp.status_code == 200 + res = resp.data["result"] + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=res["id"]) + assert res["identifier"] == "AABBCCDD" == m.identifier + assert res["type"] == "nfc_uid" == m.type + assert res["linked_giftcard"]["value"] == "0.00" + + # Ignore NFC random UID + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/lookup/?expand=linked_giftcard'.format(organizer.slug), + { + "type": "nfc_uid", + "identifier": "08080808", + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data["result"] is None diff --git a/src/tests/api/test_teams.py b/src/tests/api/test_teams.py index eb0d80d087..0ebb69819f 100644 --- a/src/tests/api/test_teams.py +++ b/src/tests/api/test_teams.py @@ -39,7 +39,7 @@ def second_team(organizer, event): TEST_TEAM_RES = { 'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True, 'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True, - 'can_manage_customers': True, + 'can_manage_customers': True, 'can_manage_reusable_media': True, 'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True, 'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False } @@ -47,7 +47,7 @@ TEST_TEAM_RES = { SECOND_TEAM_RES = { 'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'], 'can_create_events': False, - 'can_manage_customers': False, + 'can_manage_customers': False, 'can_manage_reusable_media': False, 'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False, 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False, 'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index f4c346accd..2c84159488 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -3632,3 +3632,34 @@ class OrderReactivateTest(TestCase): reactivate_order(self.order) m.refresh_from_db() assert not m.canceled + + +@pytest.mark.django_db +def test_autocreate_medium(event): + ticket = Item.objects.create(event=event, name='Early-bird ticket', issue_giftcard=True, + default_price=Decimal('23.00'), admission=True, media_type='barcode', + media_policy=Item.MEDIA_POLICY_REUSE_OR_NEW) + cp1 = CartPosition.objects.create( + item=ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123" + ) + q = event.quotas.create(size=None, name="foo") + q.items.add(ticket) + order = _create_order( + event, email='dummy@example.org', positions=[cp1], + now_dt=now(), + payment_requests=[ + { + "id": "test1", + "provider": "banktransfer", + "max_value": None, + "min_value": None, + "multi_use_supported": False, + "info_data": {}, + "pprov": BankTransfer(event), + }, + ], + locale='de' + )[0] + op = order.positions.first() + m = op.linked_media.get() + assert m.type == "barcode" diff --git a/src/tests/control/test_giftcards.py b/src/tests/control/test_giftcards.py index 855488331d..fd0f0bc701 100644 --- a/src/tests/control/test_giftcards.py +++ b/src/tests/control/test_giftcards.py @@ -19,6 +19,7 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import json from datetime import timedelta import pytest @@ -191,3 +192,27 @@ def test_manage_acceptance_permission_required(organizer, organizer2, admin_user 'add': organizer2.slug }) assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists() + + +@pytest.mark.django_db +def test_typeahead(organizer, admin_user, client, gift_card): + client.login(email='dummy@dummy.dummy', password='dummy') + with scopes_disabled(): + team = organizer.teams.get() + + # Privileged user can search + r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret[0:3]) + d = json.loads(r.content) + assert d == {"results": [{"id": gift_card.pk, "text": gift_card.secret}], "pagination": {"more": False}} + + # Unprivileged user can only do exact match + team.can_manage_gift_cards = False + team.can_manage_reusable_media = True + team.save() + + r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret[0:3]) + d = json.loads(r.content) + assert d == {"results": [], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret) + d = json.loads(r.content) + assert d == {"results": [{"id": gift_card.pk, "text": gift_card.secret}], "pagination": {"more": False}} diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 41c7a6c9ce..9c7bf626c5 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -211,6 +211,10 @@ organizer_urls = [ 'organizer/abc/customers', 'organizer/abc/customer/add', 'organizer/abc/customer/1/', + 'organizer/abc/reusable_media', + 'organizer/abc/reusable_media/add', + 'organizer/abc/reusable_media/1/', + 'organizer/abc/reusable_media/1/edit', 'organizer/abc/giftcards', 'organizer/abc/giftcard/add', 'organizer/abc/giftcard/1/', @@ -541,6 +545,9 @@ organizer_permission_urls = [ ("can_manage_customers", "organizer/dummy/customer/ABC/membership/add", 404), ("can_manage_customers", "organizer/dummy/customer/ABC/membership/1/edit", 404), ("can_manage_customers", "organizer/dummy/customer/ABC/", 404), + ("can_manage_reusable_media", "organizer/dummy/reusable_media", 200), + ("can_manage_reusable_media", "organizer/dummy/reusable_media/1/edit", 404), + ("can_manage_reusable_media", "organizer/dummy/reusable_media/1/", 404), ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), diff --git a/src/tests/control/test_reusable_media.py b/src/tests/control/test_reusable_media.py new file mode 100644 index 0000000000..ee028e3048 --- /dev/null +++ b/src/tests/control/test_reusable_media.py @@ -0,0 +1,167 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import json +from datetime import timedelta +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, Team, User + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def medium(organizer): + m = organizer.reusable_media.create(identifier="ABCDEFGH", type="barcode") + return m + + +@pytest.fixture +def gift_card(organizer): + gc = organizer.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=42) + return gc + + +@pytest.fixture +def admin_user(organizer): + u = User.objects.create_user('dummy@dummy.dummy', 'dummy') + admin_team = Team.objects.create(organizer=organizer, can_manage_reusable_media=True, name='Admin team') + admin_team.members.add(u) + return u + + +@pytest.mark.django_db +def test_list_of_media(organizer, admin_user, client, medium): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/reusable_media') + assert medium.identifier in resp.content.decode() + resp = client.get('/control/organizer/dummy/reusable_media?query=' + medium.identifier[:3]) + assert medium.identifier in resp.content.decode() + resp = client.get('/control/organizer/dummy/reusable_media?query=1234_FOO') + assert medium.identifier not in resp.content.decode() + + +@pytest.mark.django_db +def test_medium_detail_view(organizer, admin_user, medium, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/reusable_media/{}/'.format(medium.pk)) + assert medium.identifier in resp.content.decode() + + +@pytest.mark.django_db +def test_medium_add(organizer, admin_user, client, gift_card): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/organizer/dummy/reusable_media/add', { + 'type': 'barcode', + 'identifier': 'FOOBAR', + 'linked_giftcard': gift_card.pk, + }, follow=True) + assert 'FOOBAR' in resp.content.decode() + assert gift_card.secret in resp.content.decode() + with scopes_disabled(): + m = organizer.reusable_media.get() + assert m.linked_giftcard == gift_card + assert m.type == 'barcode' + assert m.identifier == 'FOOBAR' + + +@pytest.mark.django_db +def test_medium_update(organizer, admin_user, client, medium, gift_card): + client.login(email='dummy@dummy.dummy', password='dummy') + client.post(f'/control/organizer/dummy/reusable_media/{medium.pk}/edit', { + 'active': 'on', + 'linked_giftcard': gift_card.pk, + }, follow=True) + medium.refresh_from_db() + assert medium.linked_giftcard == gift_card + + +@pytest.mark.django_db +def test_typeahead(organizer, admin_user, client, gift_card): + client.login(email='dummy@dummy.dummy', password='dummy') + with scopes_disabled(): + event = organizer.events.create( + name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy' + ) + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + total=14, locale='en' + ) + ticket = 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")) + + team = organizer.teams.get() + + # Privileged user can search + team.all_events = True + team.can_view_orders = True + team.save() + + r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=DUMMY-FOO-1') + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=DUMMY-FOO') + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1') + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} + + # Unprivileged user can only do exact match + team.all_events = True + team.can_view_orders = False + team.save() + + r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) + d = json.loads(r.content) + assert d == {"results": [], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1') + d = json.loads(r.content) + assert d == {"results": [], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret) + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} + + team.all_events = False + team.can_view_orders = True + team.save() + + r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) + d = json.loads(r.content) + assert d == {"results": [], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=FOO-1') + d = json.loads(r.content) + assert d == {"results": [], "pagination": {"more": False}} + r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret) + d = json.loads(r.content) + assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}}