Add new gift card to orderposition relationship (#3291)

This commit is contained in:
Raphael Michel
2023-05-09 09:54:46 +02:00
committed by GitHub
parent 996621c028
commit 6fac1aeb62
26 changed files with 477 additions and 64 deletions
+17
View File
@@ -20,6 +20,10 @@ currency string Currency of the
testmode boolean Whether this is a test gift card testmode boolean Whether this is a test gift card
expires datetime Expiry date (or ``null``) expires datetime Expiry date (or ``null``)
conditions string Special terms and conditions for this card (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: The gift card transaction resource contains the following public fields:
@@ -72,6 +76,7 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"value": "13.37" "value": "13.37"
} }
] ]
@@ -81,6 +86,10 @@ Endpoints
:query string secret: Only show gift cards with the given secret. :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 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 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 :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -113,6 +122,7 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"value": "13.37" "value": "13.37"
} }
@@ -157,10 +167,15 @@ Endpoints
"currency": "EUR", "currency": "EUR",
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"value": "13.37" "value": "13.37"
} }
:param organizer: The ``slug`` field of the organizer to create a gift card for :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 201: no error
:statuscode 400: The gift card could not be created due to invalid submitted data. :statuscode 400: The gift card could not be created due to invalid submitted data.
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -205,6 +220,7 @@ Endpoints
"currency": "EUR", "currency": "EUR",
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"value": "14.00" "value": "14.00"
} }
@@ -250,6 +266,7 @@ Endpoints
"testmode": false, "testmode": false,
"expires": null, "expires": null,
"conditions": null, "conditions": null,
"owner_ticket": null,
"value": "15.37" "value": "15.37"
} }
+10 -10
View File
@@ -91,11 +91,11 @@ Endpoints
:query string updated_since: Only show media updated since a given date. :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_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card. :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 :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
field will be shown as a nested value instead of just an ID. The nested objects are identical to or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
the respective resources, except that the ``linked_orderposition`` will have an attribute of the The nested objects are identical to the respective resources, except that order positions
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
can be given multiple times. matching easier. The parameter can be given multiple times.
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -138,11 +138,11 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the medium 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 :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
field will be shown as a nested value instead of just an ID. The nested objects are identical to or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
the respective resources, except that the ``linked_orderposition`` will have an attribute of the The nested objects are identical to the respective resources, except that order positions
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
can be given multiple times. matching easier. The parameter can be given multiple times.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
+1
View File
@@ -201,6 +201,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('DELETE', 'api-v1:cartposition-detail'), ('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'), ('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'), ('POST', 'api-v1:giftcard-transact'),
('PATCH', 'api-v1:giftcard-detail'),
('GET', 'plugins:pretix_posbackend:posclosing-list'), ('GET', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posreceipt-list'), ('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'), ('POST', 'plugins:pretix_posbackend:posclosing-list'),
+27
View File
@@ -19,3 +19,30 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
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)
+3 -1
View File
@@ -64,7 +64,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): 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: else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
required=False, required=False,
+48 -3
View File
@@ -22,12 +22,14 @@
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers import AsymmetricField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer 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.i18n import get_language_without_region
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership, Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, MembershipType, OrderPosition, Organizer, ReusableMedium, SeatingPlan,
User, Team, TeamAPIToken, TeamInvite, User,
) )
from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import SendMailException, mail
@@ -127,8 +129,51 @@ class MembershipSerializer(I18nAwareModelSerializer):
return super().update(instance, validated_data) 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): class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00')) 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): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -151,7 +196,7 @@ class GiftCardSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = GiftCard 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): class OrderEventSlugField(serializers.RelatedField):
+27 -13
View File
@@ -179,18 +179,32 @@ class GiftCardViewSet(viewsets.ModelViewSet):
if 'include_accepted' in self.request.GET: if 'include_accepted' in self.request.GET:
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.") 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) 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') value = serializer.validated_data.pop('value', None)
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
testmode=serializer.instance.testmode) if any(k != 'value' for k in self.request.data):
diff = value - old_value inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
inst.transactions.create(value=diff) testmode=serializer.instance.testmode)
inst.log_action( inst.log_action(
'pretix.giftcards.transaction.manual', 'pretix.giftcards.modified',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'value': diff} 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 return inst
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
@@ -214,7 +228,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
data={'value': value, 'text': text} 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): def perform_destroy(self, instance):
raise MethodNotAllowed("Gift cards cannot be deleted.") raise MethodNotAllowed("Gift cards cannot be deleted.")
@@ -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'),
),
]
+7
View File
@@ -66,6 +66,13 @@ class GiftCard(LoggedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True 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( issuance = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
) )
+1
View File
@@ -1338,6 +1338,7 @@ class GiftCardFilterForm(FilterForm):
Q(secret__icontains=query) Q(secret__icontains=query)
| Q(transactions__text__icontains=query) | Q(transactions__text__icontains=query)
| Q(transactions__order__code__icontains=query) | Q(transactions__order__code__icontains=query)
| Q(owner_ticket__order__code__icontains=query)
) )
if fdata.get('testmode') == 'yes': if fdata.get('testmode') == 'yes':
qs = qs.filter(testmode=True) qs = qs.filter(testmode=True)
+27 -9
View File
@@ -186,6 +186,15 @@ class OrganizerUpdateForm(OrganizerForm):
return instance 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 EventMetaPropertyForm(forms.ModelForm):
class Meta: class Meta:
model = EventMetaProperty model = EventMetaProperty
@@ -650,23 +659,32 @@ class GiftCardCreateForm(forms.ModelForm):
class GiftCardUpdateForm(forms.ModelForm): class GiftCardUpdateForm(forms.ModelForm):
class Meta: class Meta:
model = GiftCard model = GiftCard
fields = ['expires', 'conditions'] fields = ['expires', 'conditions', 'owner_ticket']
field_classes = { field_classes = {
'expires': SplitDateTimeField 'expires': SplitDateTimeField,
'owner_ticket': SafeOrderPositionChoiceField,
} }
widgets = { widgets = {
'expires': SplitDateTimePickerWidget, 'expires': SplitDateTimePickerWidget,
'conditions': forms.Textarea(attrs={"rows": 2}) 'conditions': forms.Textarea(attrs={"rows": 2})
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
organizer = self.instance.issuer
class SafeOrderPositionChoiceField(forms.ModelChoiceField): self.fields['owner_ticket'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
def __init__(self, queryset, **kwargs): self.fields['owner_ticket'].widget = Select2(
queryset = queryset.model.all.none() attrs={
super().__init__(queryset, **kwargs) 'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
def label_from_instance(self, op): 'organizer': organizer.slug,
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' }),
'data-placeholder': _('Ticket')
}
)
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
self.fields['owner_ticket'].required = False
class ReusableMediumUpdateForm(forms.ModelForm): class ReusableMediumUpdateForm(forms.ModelForm):
@@ -631,6 +631,12 @@
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
{% for gc in line.owned_gift_cards.all %}
<div class="product-row-giftcard">
<span class="fa fa-credit-card" aria-hidden="true"></span>
<a href="{% url "control:organizer.giftcard" organizer=gc.issuer.slug giftcard=gc.pk %}">{{ gc.secret }}</a>
</div>
{% endfor %}
{% endfor %} {% endfor %}
{% for fee in items.fees %} {% for fee in items.fees %}
<div class="row-fluid product-row {% if fee.canceled %}pos-canceled{% endif %}"> <div class="row-fluid product-row {% if fee.canceled %}pos-canceled{% endif %}">
@@ -46,6 +46,14 @@
{{ card.issued_in.order.full_code }}</a>-{{ card.issued_in.positionid }} {{ card.issued_in.order.full_code }}</a>-{{ card.issued_in.positionid }}
</dd> </dd>
{% endif %} {% endif %}
{% if card.owner_ticket %}
<dt>{% trans "Owned by ticket holder" %}</dt>
<dd>
<span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=card.owner_ticket.order.event.slug organizer=request.organizer.slug code=card.owner_ticket.order.code %}">
{{ card.owner_ticket.order.code }}</a>-{{ card.owner_ticket.positionid }}
</dd>
{% endif %}
</dl> </dl>
</div> </div>
</div> </div>
@@ -14,6 +14,7 @@
{% csrf_token %} {% csrf_token %}
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.expires layout="control" %} {% bootstrap_field form.expires layout="control" %}
{% bootstrap_field form.owner_ticket layout="control" %}
{% bootstrap_field form.conditions layout="control" %} {% bootstrap_field form.conditions layout="control" %}
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">
+1 -1
View File
@@ -367,7 +367,7 @@ class OrderDetail(OrderView):
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type', 'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
'discount', 'discount',
).prefetch_related( ).prefetch_related(
'item__questions', 'issued_gift_cards', 'linked_media', 'item__questions', 'issued_gift_cards', 'owned_gift_cards', 'linked_media',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')), Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
).order_by('positionid') ).order_by('positionid')
+1 -1
View File
@@ -210,7 +210,7 @@ def giftcard_select2(request, **kwargs):
return JsonResponse(doc) return JsonResponse(doc)
@organizer_permission_required(("can_manage_reusable_media")) @organizer_permission_required(("can_manage_reusable_media", "can_manage_gift_cards"))
def ticket_select2(request, **kwargs): def ticket_select2(request, **kwargs):
query = request.GET.get('query', '') query = request.GET.get('query', '')
try: try:
@@ -369,6 +369,20 @@
{% endif %} {% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
{% for gc in line.owned_gift_cards.all %}
<div class="cart-row-giftcard">
<span class="fa fa-credit-card" aria-hidden="true"></span>
<strong>{% trans "Gift card" %}</strong>
<code>{{ gc.secret }}</code>
<strong>{% trans "Current value:" %}</strong>
{{ gc.value|money:gc.currency }}
<a href="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.giftcard" secret=line.addon_to.web_secret order=order.code pk=gc.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.giftcard" secret=line.web_secret order=order.code pk=gc.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.giftcard" secret=order.secret order=order.code pk=gc.pk %}{% endif %}"
class="btn btn-default btn-sm">
<span class="fa fa-eye" aria-hidden="true"></span>
{% trans "Details" %}
</a>
</div>
{% endfor %}
{% endfor %} {% endfor %}
{% for fee in cart.fees %} {% for fee in cart.fees %}
<div class="row cart-row"> <div class="row cart-row">
@@ -0,0 +1,29 @@
{% load i18n %}
{% load money %}
<table class="panel-body table">
<thead>
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Order" %}</th>
<th class="text-right">{% trans "Value" %}</th>
</tr>
</thead>
<tbody>
{% for t in transactions %}
<tr>
<td>{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>
{% if t.order %}
{{ t.order.full_code }}
{% else %}
<em>{% if t.text %}{{ t.text }}{% else %}{% trans "Manual transaction" %}{% endif %}</em>
{% endif %}
</td>
<td class="text-right">
{{ t.value|money:giftcard.currency }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.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 %}
<h2>
{% blocktrans trimmed with code=giftcard.secret %}
Gift card: {{ code }}
{% endblocktrans %}
<a href="{% eventurl request.event "presale:event.order" secret=order.secret order=order.code %}"
class="btn btn-default btn-sm btn-link">
<span class="fa fa-caret-left"></span>
{% trans "Back" %}
</a>
</h2>
<p>
<strong>{% trans "Current value:" %} {{ giftcard.value|money:giftcard.currency }}</strong>
</p>
<h3>{% trans "Transaction history" %}</h3>
{% include "pretixpresale/event/fragment_giftcard_history.html" %}
{% endblock %}
@@ -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 %}
<h2>
{% blocktrans trimmed with code=giftcard.secret %}
Gift card: {{ code }}
{% endblocktrans %}
<a href="{% eventurl request.event "presale:event.order.position" secret=position.web_secret order=order.code position=position.positionid %}"
class="btn btn-default btn-sm btn-link">
<span class="fa fa-caret-left"></span>
{% trans "Back" %}
</a>
</h2>
<p>
<strong>{% trans "Current value:" %} {{ giftcard.value|money:giftcard.currency }}</strong>
</p>
<h3>{% trans "Transaction history" %}</h3>
{% include "pretixpresale/event/fragment_giftcard_history.html" %}
{% endblock %}
+6
View File
@@ -140,6 +140,9 @@ event_patterns = [
re_path(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<position>[0-9]+)/(?P<output>[^/]+)$', re_path(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<position>[0-9]+)/(?P<output>[^/]+)$',
pretix.presale.views.order.OrderDownload.as_view(), pretix.presale.views.order.OrderDownload.as_view(),
name='event.order.download'), name='event.order.download'),
re_path(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/giftcard/(?P<pk>[0-9]+)/$',
pretix.presale.views.order.OrderGiftCardDetails.as_view(),
name='event.order.giftcard'),
re_path(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/invoice/(?P<invoice>[0-9]+)$', re_path(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/invoice/(?P<invoice>[0-9]+)$',
pretix.presale.views.order.InvoiceDownload.as_view(), pretix.presale.views.order.InvoiceDownload.as_view(),
name='event.invoice.download'), name='event.invoice.download'),
@@ -150,6 +153,9 @@ event_patterns = [
re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<pid>[0-9]+)/(?P<output>[^/]+)$', re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<pid>[0-9]+)/(?P<output>[^/]+)$',
pretix.presale.views.order.OrderPositionDownload.as_view(), pretix.presale.views.order.OrderPositionDownload.as_view(),
name='event.order.position.download'), name='event.order.position.download'),
re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/giftcard/(?P<pk>[0-9]+)/$',
pretix.presale.views.order.OrderPositionGiftCardDetails.as_view(),
name='event.order.position.giftcard'),
re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/change$', re_path(r'^ticket/(?P<order>[^/]+)/(?P<position>\d+)/(?P<secret>[A-Za-z0-9]+)/change$',
pretix.presale.views.order.OrderPositionChange.as_view(), pretix.presale.views.order.OrderPositionChange.as_view(),
name='event.order.position.change'), name='event.order.position.change'),
+49 -2
View File
@@ -57,7 +57,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt 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 ( from pretix.base.models import (
CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota, CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota,
@@ -241,7 +241,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**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: if self.request.event.settings.show_checkin_number_user:
qs = qs.annotate( qs = qs.annotate(
checkin_count=Subquery( checkin_count=Subquery(
@@ -1128,6 +1128,53 @@ class OrderDownload(OrderDownloadMixin, EventViewMixin, OrderDetailMixin, AsyncA
return None 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') @method_decorator(xframe_options_exempt, 'dispatch')
class OrderPositionDownload(OrderDownloadMixin, EventViewMixin, OrderPositionDetailMixin, AsyncAction, View): class OrderPositionDownload(OrderDownloadMixin, EventViewMixin, OrderPositionDetailMixin, AsyncAction, View):
task = generate task = generate
@@ -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 { h1 .btn-sm {
margin-top: -5px; margin-top: -5px;
} }
@@ -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 { .cart .firstchild-in-panel .cart-row:first-child {
padding-top: 0; padding-top: 0;
} }
+98 -21
View File
@@ -20,12 +20,14 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import copy import copy
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.models import GiftCard, Organizer from pretix.base.models import GiftCard, Order, Organizer
@pytest.fixture @pytest.fixture
@@ -50,7 +52,8 @@ TEST_GC_RES = {
"testmode": False, "testmode": False,
"expires": None, "expires": None,
"conditions": 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 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 = { TEST_GIFTCARD_CREATE_PAYLOAD = {
"secret": "DEFABC", "secret": "DEFABC",
"value": "12.00", "value": "12.00",
@@ -129,34 +189,51 @@ def test_giftcard_duplicate_secert(token_client, organizer, event, giftcard):
@pytest.mark.django_db @pytest.mark.django_db
def test_giftcard_patch(token_client, organizer, event, giftcard): def test_giftcard_patch_owner_by_id(token_client, organizer, event, giftcard):
resp = token_client.patch( with scopes_disabled():
'/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), o = Order.objects.create(
{ code='FOO', event=event, email='dummy@dummy.test',
'secret': 'foo', status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
'value': '10.00', total=14, locale='en'
'testmode': True, )
'currency': 'USD' ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
}, personalized=True)
format='json' op = o.positions.create(item=ticket, price=Decimal("14"))
)
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
resp = token_client.patch( resp = token_client.patch(
'/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk), '/api/v1/organizers/{}/giftcards/{}/'.format(organizer.slug, giftcard.pk),
{ {
'value': '9.00', 'owner_ticket': op.pk,
}, },
format='json' format='json'
) )
assert resp.status_code == 200 assert resp.status_code == 200
giftcard.refresh_from_db() 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 @pytest.mark.django_db
+9 -3
View File
@@ -121,9 +121,14 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
medium.linked_giftcard = giftcard medium.linked_giftcard = giftcard
medium.customer = customer medium.customer = customer
medium.save() medium.save()
giftcard.owner_ticket = op
giftcard.save()
resp = token_client.get( resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=linked_orderposition&expand=customer'.format( '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand='
organizer.slug, medium.pk)) 'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format(
organizer.slug, medium.pk
)
)
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.data["customer"] == { assert resp.data["customer"] == {
@@ -183,7 +188,8 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
"currency": "EUR", "currency": "EUR",
"testmode": False, "testmode": False,
"expires": None, "expires": None,
"conditions": None "conditions": None,
"owner_ticket": resp.data["linked_orderposition"],
} }