diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst index 4c8d74f1a..6756be13f 100644 --- a/doc/api/resources/giftcards.rst +++ b/doc/api/resources/giftcards.rst @@ -20,6 +20,10 @@ currency string Currency of the testmode boolean Whether this is a test gift card expires datetime Expiry date (or ``null``) conditions string Special terms and conditions for this card (or ``null``) +owner_ticket integer Internal ID of an order position that is the "owner" of + this gift card and can view all transactions. When setting + this field, you can also give the ``secret`` of an order + position. ===================================== ========================== ======================================================= The gift card transaction resource contains the following public fields: @@ -72,6 +76,7 @@ Endpoints "testmode": false, "expires": null, "conditions": null, + "owner_ticket": null, "value": "13.37" } ] @@ -81,6 +86,10 @@ Endpoints :query string secret: Only show gift cards with the given secret. :query boolean testmode: Filter for gift cards that are (not) in test mode. :query boolean include_accepted: Also show gift cards issued by other organizers that are accepted by this organizer. + :query string expand: If you pass ``"owner_ticket"``, the respective field will be shown as a nested value instead of just an ID. + The nested objects are identical to the respective resources, except that the ``owner_ticket`` + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier. The parameter can be given multiple times. :param organizer: The ``slug`` field of the organizer to fetch :statuscode 200: no error :statuscode 401: Authentication failure @@ -113,6 +122,7 @@ Endpoints "testmode": false, "expires": null, "conditions": null, + "owner_ticket": null, "value": "13.37" } @@ -157,10 +167,15 @@ Endpoints "currency": "EUR", "expires": null, "conditions": null, + "owner_ticket": null, "value": "13.37" } :param organizer: The ``slug`` field of the organizer to create a gift card for + :query string expand: If you pass ``"owner_ticket"``, the respective field will be shown as a nested value instead of just an ID. + The nested objects are identical to the respective resources, except that the ``owner_ticket`` + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier. The parameter can be given multiple times. :statuscode 201: no error :statuscode 400: The gift card could not be created due to invalid submitted data. :statuscode 401: Authentication failure @@ -205,6 +220,7 @@ Endpoints "currency": "EUR", "expires": null, "conditions": null, + "owner_ticket": null, "value": "14.00" } @@ -250,6 +266,7 @@ Endpoints "testmode": false, "expires": null, "conditions": null, + "owner_ticket": null, "value": "15.37" } diff --git a/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst index e88bc6ef4..1be7a58e0 100644 --- a/doc/api/resources/reusablemedia.rst +++ b/doc/api/resources/reusablemedia.rst @@ -91,11 +91,11 @@ Endpoints :query string updated_since: Only show media updated since a given date. :query integer linked_orderposition: Only show media linked to the given ticket. :query integer linked_giftcard: Only show media linked to the given gift card. - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective - field will be shown as a nested value instead of just an ID. The nested objects are identical to - the respective resources, except that the ``linked_orderposition`` will have an attribute of the - format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter - can be given multiple times. + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``, + or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. + The nested objects are identical to the respective resources, except that order positions + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier. The parameter can be given multiple times. :param organizer: The ``slug`` field of the organizer to fetch :statuscode 200: no error :statuscode 401: Authentication failure @@ -138,11 +138,11 @@ Endpoints :param organizer: The ``slug`` field of the organizer to fetch :param id: The ``id`` field of the medium to fetch - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective - field will be shown as a nested value instead of just an ID. The nested objects are identical to - the respective resources, except that the ``linked_orderposition`` will have an attribute of the - format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter - can be given multiple times. + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``, + or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. + The nested objects are identical to the respective resources, except that order positions + will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make + matching easier. The parameter can be given multiple times. :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index cf3635612..8fa2319a3 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -201,6 +201,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ('DELETE', 'api-v1:cartposition-detail'), ('GET', 'api-v1:giftcard-list'), ('POST', 'api-v1:giftcard-transact'), + ('PATCH', 'api-v1:giftcard-detail'), ('GET', 'plugins:pretix_posbackend:posclosing-list'), ('POST', 'plugins:pretix_posbackend:posreceipt-list'), ('POST', 'plugins:pretix_posbackend:posclosing-list'), diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py index 9fd5bdc50..7a3fa6cb7 100644 --- a/src/pretix/api/serializers/__init__.py +++ b/src/pretix/api/serializers/__init__.py @@ -19,3 +19,30 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from rest_framework import serializers + + +class AsymmetricField(serializers.Field): + def __init__(self, read, write, **kwargs): + self.read = read + self.write = write + super().__init__( + required=self.write.required, + default=self.write.default, + initial=self.write.initial, + source=self.write.source if self.write.source != self.write.field_name else None, + label=self.write.label, + allow_null=self.write.allow_null, + error_messages=self.write.error_messages, + validators=self.write.validators, + **kwargs + ) + + def to_internal_value(self, data): + return self.write.to_internal_value(data) + + def to_representation(self, value): + return self.read.to_representation(value) + + def run_validation(self, data=serializers.empty): + return self.write.run_validation(data) diff --git a/src/pretix/api/serializers/media.py b/src/pretix/api/serializers/media.py index 3e554f345..dece0ef35 100644 --- a/src/pretix/api/serializers/media.py +++ b/src/pretix/api/serializers/media.py @@ -64,7 +64,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): super().__init__(*args, **kwargs) if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): - self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True) + self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) + if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'): + self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context) else: self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( required=False, diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index a4daefab9..07a16623d 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -22,12 +22,14 @@ import logging from decimal import Decimal +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from pretix.api.serializers import AsymmetricField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.settings import SettingsSerializer @@ -35,8 +37,8 @@ from pretix.base.auth import get_auth_backends from pretix.base.i18n import get_language_without_region from pretix.base.models import ( Customer, Device, GiftCard, GiftCardTransaction, Membership, - MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, - User, + MembershipType, OrderPosition, Organizer, ReusableMedium, SeatingPlan, + Team, TeamAPIToken, TeamInvite, User, ) from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.services.mail import SendMailException, mail @@ -127,8 +129,51 @@ class MembershipSerializer(I18nAwareModelSerializer): return super().update(instance, validated_data) +class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField): + + def to_internal_value(self, data): + queryset = self.get_queryset() + + if isinstance(data, int): + try: + return queryset.get(pk=data) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=data) + + elif isinstance(data, str): + try: + return queryset.get( + Q(secret=data) + | Q(pseudonymization_id=data) + | Q(pk__in=ReusableMedium.objects.filter( + organizer=self.context['organizer'], + type='barcode', + identifier=data + )) + ) + except ObjectDoesNotExist: + self.fail('does_not_exist', pk_value=data) + + self.fail('incorrect_type', data_type=type(data).__name__) + + class GiftCardSerializer(I18nAwareModelSerializer): value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00')) + owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['owner_ticket'].queryset = OrderPosition.objects.filter(order__event__organizer=self.context['organizer']) + + if 'owner_ticket' in self.context['request'].query_params.getlist('expand'): + from pretix.api.serializers.media import ( + NestedOrderPositionSerializer, + ) + + self.fields['owner_ticket'] = AsymmetricField( + NestedOrderPositionSerializer(read_only=True, context=self.context), + self.fields['owner_ticket'], + ) def validate(self, data): data = super().validate(data) @@ -151,7 +196,7 @@ class GiftCardSerializer(I18nAwareModelSerializer): class Meta: model = GiftCard - fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions') + fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket') class OrderEventSlugField(serializers.RelatedField): diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 5f88166c3..81561afee 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -179,18 +179,32 @@ class GiftCardViewSet(viewsets.ModelViewSet): if 'include_accepted' in self.request.GET: raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.") GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) - old_value = serializer.instance.value - value = serializer.validated_data.pop('value') - inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, - testmode=serializer.instance.testmode) - diff = value - old_value - inst.transactions.create(value=diff) - inst.log_action( - 'pretix.giftcards.transaction.manual', - user=self.request.user, - auth=self.request.auth, - data={'value': diff} - ) + + value = serializer.validated_data.pop('value', None) + + if any(k != 'value' for k in self.request.data): + inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, + testmode=serializer.instance.testmode) + inst.log_action( + 'pretix.giftcards.modified', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + else: + inst = serializer.instance + + if 'value' in self.request.data and value is not None: + old_value = serializer.instance.value + diff = value - old_value + inst.transactions.create(value=diff) + inst.log_action( + 'pretix.giftcards.transaction.manual', + user=self.request.user, + auth=self.request.auth, + data={'value': diff} + ) + return inst @action(detail=True, methods=["POST"]) @@ -214,7 +228,7 @@ class GiftCardViewSet(viewsets.ModelViewSet): auth=self.request.auth, data={'value': value, 'text': text} ) - return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK) + return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) def perform_destroy(self, instance): raise MethodNotAllowed("Gift cards cannot be deleted.") diff --git a/src/pretix/base/migrations/0238_giftcard_owner_ticket.py b/src/pretix/base/migrations/0238_giftcard_owner_ticket.py new file mode 100644 index 000000000..743b3cba2 --- /dev/null +++ b/src/pretix/base/migrations/0238_giftcard_owner_ticket.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-05-04 12:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0237_question_valid_string_length'), + ] + + operations = [ + migrations.AddField( + model_name='giftcard', + name='owner_ticket', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='owned_gift_cards', to='pretixbase.orderposition'), + ), + ] diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index 5b828aae8..470b7bb21 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -66,6 +66,13 @@ class GiftCard(LoggedModel): on_delete=models.PROTECT, null=True, blank=True ) + owner_ticket = models.ForeignKey( + 'OrderPosition', + related_name='owned_gift_cards', + on_delete=models.PROTECT, + null=True, blank=True, + verbose_name=_('Owned by ticket holder') + ) issuance = models.DateTimeField( auto_now_add=True, ) diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 6a34e6fbf..701337b57 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1338,6 +1338,7 @@ class GiftCardFilterForm(FilterForm): Q(secret__icontains=query) | Q(transactions__text__icontains=query) | Q(transactions__order__code__icontains=query) + | Q(owner_ticket__order__code__icontains=query) ) if fdata.get('testmode') == 'yes': qs = qs.filter(testmode=True) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index f87b0a918..a17fea299 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -186,6 +186,15 @@ class OrganizerUpdateForm(OrganizerForm): return instance +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 EventMetaPropertyForm(forms.ModelForm): class Meta: model = EventMetaProperty @@ -650,23 +659,32 @@ class GiftCardCreateForm(forms.ModelForm): class GiftCardUpdateForm(forms.ModelForm): class Meta: model = GiftCard - fields = ['expires', 'conditions'] + fields = ['expires', 'conditions', 'owner_ticket'] field_classes = { - 'expires': SplitDateTimeField + 'expires': SplitDateTimeField, + 'owner_ticket': SafeOrderPositionChoiceField, } widgets = { 'expires': SplitDateTimePickerWidget, 'conditions': forms.Textarea(attrs={"rows": 2}) } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + organizer = self.instance.issuer -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 "")})' + self.fields['owner_ticket'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() + self.fields['owner_ticket'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={ + 'organizer': organizer.slug, + }), + 'data-placeholder': _('Ticket') + } + ) + self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices + self.fields['owner_ticket'].required = False class ReusableMediumUpdateForm(forms.ModelForm): diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index fceba6b51..aab797e9e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -631,6 +631,12 @@
+ {% for gc in line.owned_gift_cards.all %} +
+ + {{ gc.secret }} +
+ {% endfor %} {% endfor %} {% for fee in items.fees %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html index 8dea61392..032d65f70 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html @@ -46,6 +46,14 @@ {{ card.issued_in.order.full_code }}-{{ card.issued_in.positionid }} {% endif %} + {% if card.owner_ticket %} +
{% trans "Owned by ticket holder" %}
+
+ + + {{ card.owner_ticket.order.code }}-{{ card.owner_ticket.positionid }} +
+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_edit.html index 6ab7194f9..13ce0ab30 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_edit.html @@ -14,6 +14,7 @@ {% csrf_token %} {% bootstrap_form_errors form %} {% bootstrap_field form.expires layout="control" %} + {% bootstrap_field form.owner_ticket layout="control" %} {% bootstrap_field form.conditions layout="control" %}
+ {% for gc in line.owned_gift_cards.all %} +
+ + {% trans "Gift card" %} + {{ gc.secret }} + {% trans "Current value:" %} + {{ gc.value|money:gc.currency }} + + + {% trans "Details" %} + +
+ {% endfor %} {% endfor %} {% for fee in cart.fees %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_giftcard_history.html b/src/pretix/presale/templates/pretixpresale/event/fragment_giftcard_history.html new file mode 100644 index 000000000..349d7e8f3 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_giftcard_history.html @@ -0,0 +1,29 @@ +{% load i18n %} +{% load money %} + + + + + + + + + + {% for t in transactions %} + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Order" %}{% trans "Value" %}
{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if t.order %} + {{ t.order.full_code }} + {% else %} + {% if t.text %}{{ t.text }}{% else %}{% trans "Manual transaction" %}{% endif %} + {% endif %} + + {{ t.value|money:giftcard.currency }} +
+{% include "pretixcontrol/pagination.html" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_giftcard.html b/src/pretix/presale/templates/pretixpresale/event/order_giftcard.html new file mode 100644 index 000000000..e1ceb9ca8 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/order_giftcard.html @@ -0,0 +1,24 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load money %} +{% load eventurl %} +{% load l10n %} +{% load rich_text %} +{% block title %}{% trans "Gift card" %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed with code=giftcard.secret %} + Gift card: {{ code }} + {% endblocktrans %} + + + {% trans "Back" %} + +

+

+ {% trans "Current value:" %} {{ giftcard.value|money:giftcard.currency }} +

+

{% trans "Transaction history" %}

+ {% include "pretixpresale/event/fragment_giftcard_history.html" %} +{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/position_giftcard.html b/src/pretix/presale/templates/pretixpresale/event/position_giftcard.html new file mode 100644 index 000000000..764de9551 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/position_giftcard.html @@ -0,0 +1,24 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load money %} +{% load eventurl %} +{% load l10n %} +{% load rich_text %} +{% block title %}{% trans "Gift card" %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed with code=giftcard.secret %} + Gift card: {{ code }} + {% endblocktrans %} + + + {% trans "Back" %} + +

+

+ {% trans "Current value:" %} {{ giftcard.value|money:giftcard.currency }} +

+

{% trans "Transaction history" %}

+ {% include "pretixpresale/event/fragment_giftcard_history.html" %} +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 40a7c1e6f..edf34b5ba 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -140,6 +140,9 @@ event_patterns = [ re_path(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/download/(?P[0-9]+)/(?P[^/]+)$', pretix.presale.views.order.OrderDownload.as_view(), name='event.order.download'), + re_path(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/giftcard/(?P[0-9]+)/$', + pretix.presale.views.order.OrderGiftCardDetails.as_view(), + name='event.order.giftcard'), re_path(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/invoice/(?P[0-9]+)$', pretix.presale.views.order.InvoiceDownload.as_view(), name='event.invoice.download'), @@ -150,6 +153,9 @@ event_patterns = [ re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/download/(?P[0-9]+)/(?P[^/]+)$', pretix.presale.views.order.OrderPositionDownload.as_view(), name='event.order.position.download'), + re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/giftcard/(?P[0-9]+)/$', + pretix.presale.views.order.OrderPositionGiftCardDetails.as_view(), + name='event.order.position.giftcard'), re_path(r'^ticket/(?P[^/]+)/(?P\d+)/(?P[A-Za-z0-9]+)/change$', pretix.presale.views.order.OrderPositionChange.as_view(), name='event.order.position.change'), diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 2890a7746..ebbc0412b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -57,7 +57,7 @@ from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext, gettext_lazy as _ from django.views.decorators.clickjacking import xframe_options_exempt -from django.views.generic import TemplateView, View +from django.views.generic import ListView, TemplateView, View from pretix.base.models import ( CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota, @@ -241,7 +241,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin, def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - qs = self.order.positions.prefetch_related('issued_gift_cards').select_related('tax_rule') + qs = self.order.positions.prefetch_related('issued_gift_cards', 'owned_gift_cards').select_related('tax_rule') if self.request.event.settings.show_checkin_number_user: qs = qs.annotate( checkin_count=Subquery( @@ -1128,6 +1128,53 @@ class OrderDownload(OrderDownloadMixin, EventViewMixin, OrderDetailMixin, AsyncA return None +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderGiftCardDetails(EventViewMixin, OrderDetailMixin, ListView): + template_name = 'pretixpresale/event/order_giftcard.html' + context_object_name = 'transactions' + paginate_by = 50 + + @cached_property + def giftcard(self): + return GiftCard.objects.filter( + owner_ticket__order_id=self.order.pk + ).get(pk=self.kwargs['pk']) + + def get_queryset(self): + return self.giftcard.transactions.order_by('-datetime', '-pk') + + def get_context_data(self, **kwargs): + return super().get_context_data( + order=self.order, + giftcard=self.giftcard, + **kwargs, + ) + + +@method_decorator(xframe_options_exempt, 'dispatch') +class OrderPositionGiftCardDetails(EventViewMixin, OrderPositionDetailMixin, ListView): + template_name = 'pretixpresale/event/position_giftcard.html' + context_object_name = 'transactions' + paginate_by = 50 + + @cached_property + def giftcard(self): + return GiftCard.objects.filter( + Q(owner_ticket_id=self.position.pk) | Q(owner_ticket__addon_to_id=self.position.pk) + ).get(pk=self.kwargs['pk']) + + def get_queryset(self): + return self.giftcard.transactions.order_by('-datetime', '-pk') + + def get_context_data(self, **kwargs): + return super().get_context_data( + order=self.order, + position=self.position, + giftcard=self.giftcard, + **kwargs, + ) + + @method_decorator(xframe_options_exempt, 'dispatch') class OrderPositionDownload(OrderDownloadMixin, EventViewMixin, OrderPositionDetailMixin, AsyncAction, View): task = generate diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index d0c561d8b..7ad35f892 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -101,6 +101,16 @@ nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active { } } +.product-row-giftcard { + border-radius: $border-radius-base; + padding: 10px 1.4em; + margin: 0 0 5px 1.4em; + background: $panel-footer-bg; + code { + color: $text-color; + } +} + h1 .btn-sm { margin-top: -5px; } diff --git a/src/pretix/static/pretixpresale/scss/_cart.scss b/src/pretix/static/pretixpresale/scss/_cart.scss index 78f28db1f..85f28f216 100644 --- a/src/pretix/static/pretixpresale/scss/_cart.scss +++ b/src/pretix/static/pretixpresale/scss/_cart.scss @@ -106,6 +106,16 @@ } } +.cart-row-giftcard { + border-radius: $border-radius-base; + padding: 10px 1.4em; + margin: 0 0 5px 1.4em; + background: $panel-footer-bg; + code { + color: $text-color; + } +} + .cart .firstchild-in-panel .cart-row:first-child { padding-top: 0; } diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py index b81d47df5..61336942d 100644 --- a/src/tests/api/test_giftcards.py +++ b/src/tests/api/test_giftcards.py @@ -20,12 +20,14 @@ # . # import copy +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 GiftCard, Organizer +from pretix.base.models import GiftCard, Order, Organizer @pytest.fixture @@ -50,7 +52,8 @@ TEST_GC_RES = { "testmode": False, "expires": None, "conditions": None, - "currency": "EUR" + "currency": "EUR", + "owner_ticket": None } @@ -93,6 +96,63 @@ def test_giftcard_detail(token_client, organizer, event, giftcard): assert res == resp.data +@pytest.mark.django_db +def test_giftcard_detail_expand(token_client, organizer, event, giftcard): + 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")) + giftcard.owner_ticket = op + giftcard.save() + + res = dict(TEST_GC_RES) + res["id"] = giftcard.pk + res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z') + resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/?expand=owner_ticket'.format(organizer.slug, giftcard.pk)) + assert resp.status_code == 200 + + assert resp.data["owner_ticket"] == { + "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 + } + + TEST_GIFTCARD_CREATE_PAYLOAD = { "secret": "DEFABC", "value": "12.00", @@ -129,34 +189,51 @@ def test_giftcard_duplicate_secert(token_client, organizer, event, giftcard): @pytest.mark.django_db -def test_giftcard_patch(token_client, organizer, event, giftcard): - resp = token_client.patch( - '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), - { - 'secret': 'foo', - 'value': '10.00', - 'testmode': True, - 'currency': 'USD' - }, - format='json' - ) - assert resp.status_code == 200 - giftcard.refresh_from_db() - assert giftcard.value == Decimal('10.00') - assert giftcard.secret == "ABCDEF" - assert giftcard.currency == "EUR" - assert not giftcard.testmode +def test_giftcard_patch_owner_by_id(token_client, organizer, event, giftcard): + 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")) resp = token_client.patch( '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), { - 'value': '9.00', + 'owner_ticket': op.pk, }, format='json' ) assert resp.status_code == 200 giftcard.refresh_from_db() - assert giftcard.value == Decimal('9.00') + assert giftcard.owner_ticket == op + + +@pytest.mark.django_db +def test_giftcard_patch_owner_by_secret(token_client, organizer, event, giftcard): + 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")) + + resp = token_client.patch( + '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), + { + 'owner_ticket': op.secret, + }, + format='json' + ) + assert resp.status_code == 200 + giftcard.refresh_from_db() + assert giftcard.owner_ticket == op @pytest.mark.django_db diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index 5510f0b8d..57ab79f29 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -121,9 +121,14 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome medium.linked_giftcard = giftcard medium.customer = customer medium.save() + giftcard.owner_ticket = op + giftcard.save() resp = token_client.get( - '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=linked_orderposition&expand=customer'.format( - organizer.slug, medium.pk)) + '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=' + 'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format( + organizer.slug, medium.pk + ) + ) assert resp.status_code == 200 assert resp.data["customer"] == { @@ -183,7 +188,8 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome "currency": "EUR", "testmode": False, "expires": None, - "conditions": None + "conditions": None, + "owner_ticket": resp.data["linked_orderposition"], }