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 %}
+
+ {% 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 %}
+
+
+
+ | {% trans "Date" %} |
+ {% trans "Order" %} |
+ {% trans "Value" %} |
+
+
+
+ {% for t in transactions %}
+
+ | {{ 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 }}
+ |
+
+ {% endfor %}
+
+
+{% 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