forked from CGM_Public/pretix_original
Add pluggable ticket secret generators (#1809)
This commit is contained in:
@@ -99,7 +99,7 @@ Example::
|
||||
Log violations of the Content Security Policy (CSP). Defaults to ``on``.
|
||||
|
||||
``loglevel``
|
||||
Set console and file loglevel (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||
Set console and file log level (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR`` or ``CRITICAL``). Defaults to ``INFO``.
|
||||
|
||||
Locale settings
|
||||
---------------
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ On a failed registration, a query string like ``?error=access_denied`` will be a
|
||||
|
||||
.. note:: By default, the user is asked to give permission on every call to this URL. If you **only** request the
|
||||
``profile`` scope, i.e. no access to organizer data, you can pass the ``approval_prompt=auto`` parameter
|
||||
to skip user interaction on subsequen calls.
|
||||
to skip user interaction on subsequent calls.
|
||||
|
||||
Getting an access token
|
||||
-----------------------
|
||||
|
||||
@@ -216,7 +216,7 @@ Device endpoints
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param device_id: The ``device_id`` field of the deviec to modify
|
||||
:param device_id: The ``device_id`` field of the device to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The device could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
@@ -44,6 +44,9 @@ seat_category_mapping object An object mappi
|
||||
(strings) to items in the event (integers or ``null``).
|
||||
timezone string Event timezone name
|
||||
item_meta_properties object Item-specific meta data parameters and default values.
|
||||
valid_keys object Cryptographic keys for non-default signature schemes.
|
||||
For performance reason, value is omitted in lists and
|
||||
only contained in detail views. Value can be cached.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -84,6 +87,10 @@ item_meta_properties object Item-specific m
|
||||
|
||||
The attribute ``item_meta_properties`` has been added.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
The attribute ``valid_keys`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -144,7 +151,7 @@ Endpoints
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.ticketoutputpdf"
|
||||
]
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -216,7 +223,12 @@ Endpoints
|
||||
"pretix.plugins.stripe"
|
||||
"pretix.plugins.paypal"
|
||||
"pretix.plugins.ticketoutputpdf"
|
||||
]
|
||||
],
|
||||
"valid_keys": {
|
||||
"pretix_sig1": [
|
||||
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
|
||||
@@ -2263,3 +2263,57 @@ Order refund endpoints
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order or refund does not exist.
|
||||
|
||||
Revoked ticket secrets
|
||||
----------------------
|
||||
|
||||
With some non-default ticket secret generation methods, a list of revoked ticket secrets is required for proper validation.
|
||||
|
||||
.. versionchanged:: 3.12
|
||||
|
||||
Added revocation lists.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/revokedsecrets/
|
||||
|
||||
Returns a list of all revoked secrets within a given event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/revokedsecrets/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
X-Page-Generated: 2017-12-01T10:00:00Z
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1234,
|
||||
"secret": "k24fiuwvu8kxz3y1",
|
||||
"created": "2017-12-01T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``secret`` and ``created``. Default: ``-created``
|
||||
:query datetime created_since: Only return revocations that have been created since the given date.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
|
||||
differences, this is the value you want to use as ``created_since`` in your next call.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
@@ -12,7 +12,8 @@ Core
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter
|
||||
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
|
||||
register_ticket_secret_generators
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
|
||||
@@ -117,7 +117,7 @@ for example, to check for any errors in any staged files when committing::
|
||||
export GIT_WORK_TREE=../
|
||||
export GIT_DIR=../.git
|
||||
source ../env/bin/activate # Adjust to however you activate your virtual environment
|
||||
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
|
||||
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py|.*_pb2\.py")
|
||||
do
|
||||
echo $file
|
||||
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
|
||||
|
||||
@@ -3,7 +3,7 @@ sphinx==2.3.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-spelling
|
||||
sphinxcontrib-spelling==4.*
|
||||
pygments-markdown-lexer
|
||||
# See https://github.com/rfk/pyenchant/pull/130
|
||||
git+https://github.com/raphaelm/pyenchant.git@patch-1#egg=pyenchant
|
||||
|
||||
@@ -10,6 +10,8 @@ availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
barcode
|
||||
barcodes
|
||||
Bcc
|
||||
boolean
|
||||
booleans
|
||||
@@ -53,6 +55,7 @@ iframe
|
||||
incrementing
|
||||
inofficial
|
||||
invalidations
|
||||
iOS
|
||||
iterable
|
||||
Jimdo
|
||||
jwt
|
||||
@@ -91,6 +94,7 @@ prepending
|
||||
preprocessor
|
||||
presale
|
||||
pretix
|
||||
pretixSCAN
|
||||
pretixdroid
|
||||
pretixpresale
|
||||
prometheus
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Ticket secret generators
|
||||
========================
|
||||
|
||||
pretix allows you to change the way in which ticket secrets (also known as "ticket codes", "barcodes", …)
|
||||
are generated. This affects the value of the QR code in any tickets issued by pretix, regardless of ticket
|
||||
format.
|
||||
|
||||
.. note:: This is intended for highly advanced use cases, usually when huge numbers of tickets (> 25k per event)
|
||||
are involved. **If you don't know whether you need this, you probably don't.**
|
||||
|
||||
Default: Random secrets
|
||||
-----------------------
|
||||
|
||||
By default, pretix generates a random code for every ticket, consisting of 32 lower case characters and
|
||||
numbers. The characters ``oO1il`` are avoided to reduce confusion when ticket codes are printed and need to
|
||||
be typed in manually.
|
||||
|
||||
Choosing random codes has a number of advantages:
|
||||
|
||||
* Ticket codes are short, which makes QR codes easier to scan. At the same time, it is absolutely impossible to
|
||||
guess or forge a valid ticket code.
|
||||
|
||||
* The code does not need to change if the ticket changes. For example, if an attendee is re-booked to a
|
||||
different product or date, they can keep their ticket and it is just mapped to the new product in the
|
||||
database.
|
||||
|
||||
This approach works really well for 99 % or events running with pretix.
|
||||
The big caveat is that the scanner needs to access a database of all ticket codes in order to know whether a ticket
|
||||
code is valid and what kind of ticket it represents.
|
||||
|
||||
When scanning online this is no problem at all, since the pretix server always has such a database. In case your local
|
||||
internet connection is interrupted or the pretix server goes down, though, there needs to be a database locally on the
|
||||
scanner.
|
||||
|
||||
Therefore, our pretixSCAN apps by default download the database of all valid tickets onto the device itself. This makes
|
||||
it possible to seamlessly switch into offline mode when the connection is lost and continue scanning with the maximum
|
||||
possible feature set.
|
||||
|
||||
There are a few situations in which this approach is not ideal:
|
||||
|
||||
* When running a single event with 25k or more valid tickets, downloading all ticket data onto the scanner may just
|
||||
take too much time and resources.
|
||||
|
||||
* When the risk of losing sensible data by losing one of the scanner devices is not acceptable.
|
||||
|
||||
* When offline mode needs to be used regularly and newly-purchased tickets need to be valid immediately after purchase,
|
||||
without being able to tolerate a few minutes of delay.
|
||||
|
||||
Signature schemes
|
||||
-----------------
|
||||
|
||||
The alternative approach that is included with pretix is to choose a signature-based ticket code generation scheme.
|
||||
These secrets include the most important information that is required for verifying their validity and use modern
|
||||
cryptography to make sure they cannot be forged.
|
||||
|
||||
Currently, pretix ships with one such scheme ("pretix signature scheme 1") which encodes the product, the product
|
||||
variation, and the date (if inside an event series) into the ticket code and signs the code with a `EdDSA`_ signature.
|
||||
This allows to verify whether a ticket is allowed to enter without any database or connection to the server, but has
|
||||
a few important drawbacks:
|
||||
|
||||
* Whenever the product, variation or date of a ticket changes or the ticket is canceled, the ticket code needs to be
|
||||
changed and the old code needs to be put on a revocation list. This revocation list again needs to be downloaded by
|
||||
all scanning devices (but is usually much smaller than the ticket database). The main downside is that the attendee
|
||||
needs to download their new ticket and can no longer use the old one.
|
||||
|
||||
* Scanning in offline mode is much more limited, since the scanner has no information about previous usages of the
|
||||
ticket, attendee names, seating information, etc.
|
||||
|
||||
Comparison of scanning behavior
|
||||
-------------------------------
|
||||
|
||||
=============================================== =================================== =================================== =================================== ================================= =====================================
|
||||
Scan mode Online Offline
|
||||
----------------------------------------------- ----------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------
|
||||
Synchronization setting any Synchronize orders Don't synchronize orders
|
||||
----------------------------------------------- ----------------------------------- ----------------------------------------------------------------------- -----------------------------------------------------------------------
|
||||
Ticket secrets any Random Signed Random Signed
|
||||
=============================================== =================================== =================================== =================================== ================================= =====================================
|
||||
Scenario supported on platforms Android, Desktop, iOS Android, Desktop, iOS Android, Desktop Android, Desktop Android, Desktop
|
||||
Synchronization speed for large data sets slow slow fast fast
|
||||
Tickets can be scanned yes yes yes no yes
|
||||
Ticket is valid after sale immediately next sync (~5 minutes) immediately never immediately
|
||||
Same ticket can be scanned multiple times no yes, before data is synced yes, before data is synced n/a yes, always
|
||||
Custom check-in rules yes yes yes (limited directly after sale) n/a yes, but only based on product,
|
||||
variation and date, not on previous
|
||||
scans
|
||||
Name and seat visible on scanner yes yes yes (except directly after sale) n/a no
|
||||
Order-specific check-in attention flag yes yes yes (except directly after sale) n/a no
|
||||
Ticket search by order code or name yes yes yes (except directly after sale) no no
|
||||
Check-in statistics on scanner yes yes mostly accurate no no
|
||||
=============================================== =================================== =================================== =================================== ================================= =====================================
|
||||
|
||||
.. _EdDSA: https://en.wikipedia.org/wiki/EdDSA#Ed25519
|
||||
+22
-15
@@ -9,26 +9,33 @@ At "Settings" → "Tickets", you can configure the ticket download options that
|
||||
|
||||
The top of this page shows a short list of options relevant for all download formats:
|
||||
|
||||
Use feature
|
||||
Allow users to download tickets
|
||||
This can be used to completely enable or disable ticket downloads all over your ticket shop.
|
||||
|
||||
Generate tickets for add-on products
|
||||
By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If
|
||||
you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled,
|
||||
you can still print a list of chosen add-ons e.g. on the PDF tickets.
|
||||
|
||||
Generate tickets for all products
|
||||
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
|
||||
generate tickets for all products instead.
|
||||
|
||||
Generate tickets for pending orders
|
||||
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
|
||||
the event, you can check this box to enable ticket download even before.
|
||||
|
||||
Download date
|
||||
If you set a date here, no ticket download will be offered before this date. If no date is set, tickets can be
|
||||
downloaded immediately after the payment for an order has been received.
|
||||
|
||||
Offer to download tickets separately for add-on products
|
||||
By default, tickets can not be downloaded for order positions which are only an add-on to other order positions. If
|
||||
you enable this, this behavior will be changed and add-on products will get their own tickets as well. If disabled,
|
||||
you can still print a list of chosen add-ons e.g. on the PDF tickets.
|
||||
|
||||
Generate tickets for non-admission products
|
||||
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
|
||||
generate tickets for all products instead.
|
||||
|
||||
Offer to download tickets even before an order is paid
|
||||
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
|
||||
the event, you can check this box to enable ticket download even before.
|
||||
|
||||
Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to
|
||||
format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with
|
||||
a PDF output plugin that you can configure through a visual design editor.
|
||||
a PDF output plugin that you can configure through a visual design editor.
|
||||
|
||||
**Advanced topics:**
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
ticket_secrets
|
||||
|
||||
@@ -37,6 +37,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('GET', 'api-v1:checkinlistpos-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:order-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
@@ -61,6 +62,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('GET', 'api-v1:checkinlist-status'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
|
||||
@@ -98,6 +100,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'plugins:pretix_posbackend:posclosing-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,19 +95,41 @@ class TimeZoneField(ChoiceField):
|
||||
)
|
||||
|
||||
|
||||
class ValidKeysField(Field):
|
||||
def to_representation(self, value):
|
||||
return value.cache.get_or_set(
|
||||
'ticket_secret_valid_keys',
|
||||
lambda: self._get(value),
|
||||
120
|
||||
)
|
||||
|
||||
def _get(self, value):
|
||||
return {
|
||||
'pretix_sig1': [
|
||||
value.settings.ticket_secrets_pretix_sig1_pubkey
|
||||
] if value.settings.ticket_secrets_pretix_sig1_pubkey else []
|
||||
}
|
||||
|
||||
|
||||
class EventSerializer(I18nAwareModelSerializer):
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
item_meta_properties = MetaPropertyField(required=False, source='*')
|
||||
plugins = PluginsField(required=False, source='*')
|
||||
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
|
||||
timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones])
|
||||
valid_keys = ValidKeysField(source='*', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
|
||||
'date_to', 'date_admission', 'is_public', 'presale_start',
|
||||
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
|
||||
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties')
|
||||
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not hasattr(self.context['request'], 'event'):
|
||||
self.fields.pop('valid_keys')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
@@ -21,7 +21,7 @@ from pretix.base.models import (
|
||||
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret,
|
||||
)
|
||||
from pretix.base.pdf import get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
@@ -1209,3 +1209,10 @@ class OrderRefundCreateSerializer(I18nAwareModelSerializer):
|
||||
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
|
||||
order.save()
|
||||
return order
|
||||
|
||||
|
||||
class RevokedTicketSecretSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RevokedTicketSecret
|
||||
fields = ('id', 'secret', 'created')
|
||||
|
||||
@@ -39,6 +39,7 @@ event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
event_router.register(r'orders', order.OrderViewSet)
|
||||
event_router.register(r'orderpositions', order.OrderPositionViewSet)
|
||||
event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||
event_router.register(r'taxrules', event.TaxRuleViewSet)
|
||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||
|
||||
@@ -278,13 +278,23 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
else:
|
||||
op = queryset.get(secret=self.kwargs['pk'])
|
||||
except OrderPosition.DoesNotExist:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
|
||||
if len(revoked_matches) == 0 or not force:
|
||||
self.request.event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
raise Http404()
|
||||
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk,
|
||||
'barcode': self.kwargs['pk']
|
||||
}, user=self.request.user, auth=self.request.auth)
|
||||
raise Http404()
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
@@ -325,6 +335,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'errorcode': e.code,
|
||||
'force': force,
|
||||
'datetime': dt,
|
||||
'type': type,
|
||||
'list': self.checkinlist.pk
|
||||
|
||||
@@ -89,7 +89,6 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
qs = filter_qs_by_attr(qs, self.request)
|
||||
|
||||
return qs.prefetch_related(
|
||||
'meta_values', 'meta_values__property', 'seat_category_mappings'
|
||||
)
|
||||
|
||||
@@ -26,15 +26,18 @@ from pretix.api.serializers.order import (
|
||||
InvoiceSerializer, OrderCreateSerializer, OrderPaymentCreateSerializer,
|
||||
OrderPaymentSerializer, OrderPositionSerializer,
|
||||
OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer,
|
||||
PriceCalcSerializer, SimulatedOrderSerializer,
|
||||
PriceCalcSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer,
|
||||
)
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
|
||||
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
|
||||
TeamAPIToken, generate_position_secret, generate_secret,
|
||||
TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import RevokedTicketSecret
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||
@@ -483,8 +486,9 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
order = self.get_object()
|
||||
order.secret = generate_secret()
|
||||
for op in order.all_positions.all():
|
||||
op.secret = generate_position_secret()
|
||||
op.save()
|
||||
assign_ticket_secret(
|
||||
request.event, op, force_invalidate=True, save=True
|
||||
)
|
||||
order.save(update_fields=['secret'])
|
||||
CachedTicket.objects.filter(order_position__order=order).delete()
|
||||
CachedCombinedTicket.objects.filter(order=order).delete()
|
||||
@@ -1298,3 +1302,26 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
with scopes_disabled():
|
||||
class RevokedSecretFilter(FilterSet):
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
|
||||
class Meta:
|
||||
model = RevokedTicketSecret
|
||||
fields = []
|
||||
|
||||
|
||||
class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = RevokedTicketSecretSerializer
|
||||
queryset = RevokedTicketSecret.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('-created',)
|
||||
ordering_fields = ('created', 'secret')
|
||||
filterset_class = RevokedSecretFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return RevokedTicketSecret.objects.filter(event=self.request.event)
|
||||
|
||||
@@ -482,7 +482,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
|
||||
field=models.CharField(db_index=True, default="invalid", max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
|
||||
@@ -17,6 +17,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(default=pretix.base.models.orders.generate_position_secret, max_length=64),
|
||||
field=models.CharField(default="invalid", max_length=64),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -38,7 +38,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_position_secret, max_length=64),
|
||||
field=models.CharField(db_index=True, default="invalid", max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='voucher',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.0.10 on 2020-10-15 19:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0164_subevent_last_modified'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, max_length=64),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RevokedTicketSecret',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('secret', models.TextField(db_index=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revoked_secrets', to='pretixbase.Event')),
|
||||
('position', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revoked_secrets', to='pretixbase.OrderPosition')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.10 on 2020-10-15 20:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0165_auto_20201015_1924'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='secret',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
]
|
||||
@@ -662,7 +662,14 @@ class Event(EventMixin, LoggedModel):
|
||||
s.product = item_map[s.product_id]
|
||||
s.save()
|
||||
|
||||
skip_settings = (
|
||||
'ticket_secrets_pretix_sig1_pubkey',
|
||||
'ticket_secrets_pretix_sig1_privkey',
|
||||
)
|
||||
for s in other.settings._objects.all():
|
||||
if s.key in skip_settings:
|
||||
continue
|
||||
|
||||
s.object = self
|
||||
s.pk = None
|
||||
if s.value.startswith('file://'):
|
||||
@@ -754,6 +761,31 @@ class Event(EventMixin, LoggedModel):
|
||||
renderers[pp.identifier] = pp
|
||||
return renderers
|
||||
|
||||
@cached_property
|
||||
def ticket_secret_generators(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of cached initialized ticket secret generators mapped by their identifiers.
|
||||
"""
|
||||
from ..signals import register_ticket_secret_generators
|
||||
|
||||
responses = register_ticket_secret_generators.send(self)
|
||||
renderers = {}
|
||||
for receiver, response in responses:
|
||||
if not isinstance(response, list):
|
||||
response = [response]
|
||||
for p in response:
|
||||
pp = p(self)
|
||||
renderers[pp.identifier] = pp
|
||||
return renderers
|
||||
|
||||
@property
|
||||
def ticket_secret_generator(self):
|
||||
"""
|
||||
Returns the currently configured ticket secret generator.
|
||||
"""
|
||||
tsgs = self.ticket_secret_generators
|
||||
return tsgs[self.settings.ticket_secret_generator]
|
||||
|
||||
def get_data_shredders(self) -> dict:
|
||||
"""
|
||||
Returns a dictionary of initialized data shredders mapped by their identifiers.
|
||||
|
||||
@@ -57,8 +57,7 @@ def generate_secret():
|
||||
|
||||
|
||||
def generate_position_secret():
|
||||
# Exclude o,0,1,i,l to avoid confusion with bad fonts/printers
|
||||
return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789')
|
||||
raise TypeError("Function no longer exists, use secret generators")
|
||||
|
||||
|
||||
class Order(LockModel, LoggedModel):
|
||||
@@ -1938,7 +1937,7 @@ class OrderPosition(AbstractPosition):
|
||||
max_digits=10, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
|
||||
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
|
||||
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
||||
pseudonymization_id = models.CharField(
|
||||
max_length=16,
|
||||
@@ -2031,13 +2030,18 @@ class OrderPosition(AbstractPosition):
|
||||
self.tax_rate = Decimal('0.00')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
if not self.pk:
|
||||
while OrderPosition.all.filter(secret=self.secret,
|
||||
order__event__organizer_id=self.order.event.organizer_id).exists():
|
||||
self.secret = generate_position_secret()
|
||||
while not self.secret or OrderPosition.all.filter(
|
||||
secret=self.secret, order__event__organizer_id=self.order.event.organizer_id
|
||||
).exists():
|
||||
assign_ticket_secret(
|
||||
event=self.order.event, position=self, force_invalidate=True, save=False
|
||||
)
|
||||
|
||||
if not self.pseudonymization_id:
|
||||
self.assign_pseudonymization_id()
|
||||
@@ -2326,6 +2330,18 @@ class CancellationRequest(models.Model):
|
||||
refund_as_giftcard = models.BooleanField(default=False)
|
||||
|
||||
|
||||
class RevokedTicketSecret(models.Model):
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='revoked_secrets')
|
||||
position = models.ForeignKey(
|
||||
OrderPosition,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='revoked_secrets',
|
||||
null=True,
|
||||
)
|
||||
secret = models.TextField(db_index=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedTicket)
|
||||
def cachedticket_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
|
||||
@@ -48,7 +48,9 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
("secret", {
|
||||
"label": _("Ticket code (barcode content)"),
|
||||
"editor_sample": "tdmruoekvkpbv1o2mv8xccvqcikvr58u",
|
||||
"evaluate": lambda orderposition, order, event: orderposition.secret
|
||||
"evaluate": lambda orderposition, order, event: (
|
||||
orderposition.secret[:30] + "…" if len(orderposition.secret) > 32 else orderposition.secret
|
||||
)
|
||||
}),
|
||||
("order", {
|
||||
"label": _("Order code"),
|
||||
@@ -427,8 +429,13 @@ class Renderer:
|
||||
elif content == 'pseudonymization_id':
|
||||
content = op.pseudonymization_id
|
||||
|
||||
level = 'H'
|
||||
if len(content) > 32:
|
||||
level = 'M'
|
||||
if len(content) > 128:
|
||||
level = 'L'
|
||||
reqs = float(o['size']) * mm
|
||||
qrw = QrCodeWidget(content, barLevel='H', barHeight=reqs, barWidth=reqs)
|
||||
qrw = QrCodeWidget(content, barLevel=level, barHeight=reqs, barWidth=reqs)
|
||||
d = Drawing(reqs, reqs)
|
||||
d.add(qrw)
|
||||
qr_x = float(o['left']) * mm
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option java_package = "eu.pretix.libpretixsync.crypto.sig1";
|
||||
option java_outer_classname = "TicketProtos";
|
||||
|
||||
message Ticket {
|
||||
string seed = 1;
|
||||
int64 item = 2;
|
||||
int64 variation = 3;
|
||||
int64 subevent = 4;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: pretix_sig1.proto
|
||||
|
||||
from google.protobuf import (
|
||||
descriptor as _descriptor, message as _message, reflection as _reflection,
|
||||
symbol_database as _symbol_database,
|
||||
)
|
||||
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||
name='pretix_sig1.proto',
|
||||
package='',
|
||||
syntax='proto3',
|
||||
serialized_options=b'\n\026eu.pretix.secrets.sig1B\014TicketProtos',
|
||||
create_key=_descriptor._internal_create_key,
|
||||
serialized_pb=b'\n\x11pretix_sig1.proto\"I\n\x06Ticket\x12\x0c\n\x04seed\x18\x01 \x01(\t\x12\x0c\n\x04item\x18\x02 \x01(\x03\x12\x11\n\tvariation\x18\x03 \x01(\x03\x12\x10\n\x08subevent\x18\x04 \x01(\x03\x42&\n\x16\x65u.pretix.secrets.sig1B\x0cTicketProtosb\x06proto3'
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
_TICKET = _descriptor.Descriptor(
|
||||
name='Ticket',
|
||||
full_name='Ticket',
|
||||
filename=None,
|
||||
file=DESCRIPTOR,
|
||||
containing_type=None,
|
||||
create_key=_descriptor._internal_create_key,
|
||||
fields=[
|
||||
_descriptor.FieldDescriptor(
|
||||
name='seed', full_name='Ticket.seed', index=0,
|
||||
number=1, type=9, cpp_type=9, label=1,
|
||||
has_default_value=False, default_value=b"".decode('utf-8'),
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='item', full_name='Ticket.item', index=1,
|
||||
number=2, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='variation', full_name='Ticket.variation', index=2,
|
||||
number=3, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
_descriptor.FieldDescriptor(
|
||||
name='subevent', full_name='Ticket.subevent', index=3,
|
||||
number=4, type=3, cpp_type=2, label=1,
|
||||
has_default_value=False, default_value=0,
|
||||
message_type=None, enum_type=None, containing_type=None,
|
||||
is_extension=False, extension_scope=None,
|
||||
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
|
||||
],
|
||||
extensions=[
|
||||
],
|
||||
nested_types=[],
|
||||
enum_types=[
|
||||
],
|
||||
serialized_options=None,
|
||||
is_extendable=False,
|
||||
syntax='proto3',
|
||||
extension_ranges=[],
|
||||
oneofs=[
|
||||
],
|
||||
serialized_start=21,
|
||||
serialized_end=94,
|
||||
)
|
||||
|
||||
DESCRIPTOR.message_types_by_name['Ticket'] = _TICKET
|
||||
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||
|
||||
Ticket = _reflection.GeneratedProtocolMessageType('Ticket', (_message.Message,), {
|
||||
'DESCRIPTOR' : _TICKET,
|
||||
'__module__' : 'pretix_sig1_pb2'
|
||||
# @@protoc_insertion_point(class_scope:Ticket)
|
||||
})
|
||||
_sym_db.RegisterMessage(Ticket)
|
||||
|
||||
|
||||
DESCRIPTOR._options = None
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,202 @@
|
||||
import base64
|
||||
import struct
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import Backend
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization.base import (
|
||||
Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Item, ItemVariation, SubEvent
|
||||
from pretix.base.secretgenerators import pretix_sig1_pb2
|
||||
from pretix.base.signals import register_ticket_secret_generators
|
||||
|
||||
|
||||
class BaseTicketSecretGenerator:
|
||||
"""
|
||||
This is the base class to be used for all ticket secret generators.
|
||||
"""
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name for this generator. This should be short but self-explanatory.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""
|
||||
A short and unique identifier for this renderer. This should only contain lowercase letters
|
||||
and in most cases will be the same as your package name.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
@property
|
||||
def use_revocation_list(self):
|
||||
"""
|
||||
If this attribute is set to ``True``, the system will set all no-longer-used secrets on a revocation list.
|
||||
This is not required for pretix' default method of just using random identifiers as ticket secrets
|
||||
since all ticket scans will be compared to the database. However, if your secret generation method
|
||||
is designed to allow offline verification without a ticket database, all invalidated/replaced
|
||||
secrets as well as all secrets of canceled tickets will need to go to a revocation list.
|
||||
"""
|
||||
return False
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False) -> str:
|
||||
"""
|
||||
Generate a new secret for a ticket with product ``item``, variation ``variation``, subevent ``subevent``,
|
||||
and the current secret ``current_secret`` (if any).
|
||||
|
||||
The result must be a string that should only contain the characters ``A-Za-z0-9+/=``.
|
||||
|
||||
The algorithm is expected to conform to the following rules:
|
||||
|
||||
If ``force_invalidate`` is set to ``True``, the method MUST return a different secret than ``current_secret``,
|
||||
such that ``current_secret`` can get revoked.
|
||||
|
||||
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have the same value
|
||||
as when ``current_secret`` was generated, then this method MUST return ``current_secret`` unchanged.
|
||||
|
||||
If ``force_invalidate`` is set to ``False`` and ``item``, ``variation`` and ``subevent`` have a different value
|
||||
as when ``current_secret`` was generated, then this method MAY OR MAY NOT return ``current_secret`` unchanged,
|
||||
depending on the semantics of the method.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class RandomTicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
verbose_name = _('Random (default, works with all pretix apps)')
|
||||
identifier = 'random'
|
||||
use_revocation_list = False
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False):
|
||||
if current_secret and not force_invalidate:
|
||||
return current_secret
|
||||
return get_random_string(
|
||||
length=settings.ENTROPY['ticket_secret'],
|
||||
# Exclude o,0,1,i,l to avoid confusion with bad fonts/printers
|
||||
allowed_chars='abcdefghjkmnpqrstuvwxyz23456789'
|
||||
)
|
||||
|
||||
|
||||
class Sig1TicketSecretGenerator(BaseTicketSecretGenerator):
|
||||
"""
|
||||
Secret generator for signed QR codes.
|
||||
|
||||
QR-code format:
|
||||
|
||||
- 1 Byte with the version of the scheme, currently 0x01
|
||||
- 2 Bytes length of the payload (big-endian) => n
|
||||
- 2 Bytes length of the signature (big-endian) => m
|
||||
- n Bytes payload (with protobuf encoding)
|
||||
- m Bytes ECDSA signature of Sign(payload)
|
||||
|
||||
The resulting string is REVERSED, to avoid all secrets of same length beginning with the same 10
|
||||
characters, which would make it impossible to search for secrets manually.
|
||||
"""
|
||||
verbose_name = _('pretix signature scheme 1 (for very large events, does not work with pretixSCAN on iOS and '
|
||||
'changes semantics of offline scanning – please refer to documentation or support for details)')
|
||||
identifier = 'pretix_sig1'
|
||||
use_revocation_list = True
|
||||
|
||||
def _generate_keys(self):
|
||||
privkey = Ed25519PrivateKey.generate()
|
||||
pubkey = privkey.public_key()
|
||||
self.event.settings.ticket_secrets_pretix_sig1_privkey = base64.b64encode(privkey.private_bytes(
|
||||
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
|
||||
)).decode()
|
||||
self.event.settings.ticket_secrets_pretix_sig1_pubkey = base64.b64encode(pubkey.public_bytes(
|
||||
Encoding.PEM, PublicFormat.SubjectPublicKeyInfo
|
||||
)).decode()
|
||||
|
||||
def _sign_payload(self, payload):
|
||||
if not self.event.settings.ticket_secrets_pretix_sig1_privkey:
|
||||
self._generate_keys()
|
||||
privkey = load_pem_private_key(
|
||||
base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_privkey), None, Backend()
|
||||
)
|
||||
signature = privkey.sign(payload)
|
||||
return (
|
||||
bytes([0x01])
|
||||
+ struct.pack(">H", len(payload))
|
||||
+ struct.pack(">H", len(signature))
|
||||
+ payload
|
||||
+ signature
|
||||
)
|
||||
|
||||
def _parse(self, secret):
|
||||
try:
|
||||
rawbytes = base64.b64decode(secret[::-1])
|
||||
if rawbytes[0] != 1:
|
||||
raise ValueError('Invalid version')
|
||||
|
||||
payload_len = struct.unpack(">H", rawbytes[1:3])[0]
|
||||
sig_len = struct.unpack(">H", rawbytes[3:5])[0]
|
||||
payload = rawbytes[5:5 + payload_len]
|
||||
signature = rawbytes[5 + payload_len:5 + payload_len + sig_len]
|
||||
pubkey = load_pem_public_key(
|
||||
base64.b64decode(self.event.settings.ticket_secrets_pretix_sig1_pubkey), Backend()
|
||||
)
|
||||
pubkey.verify(signature, payload)
|
||||
t = pretix_sig1_pb2.Ticket()
|
||||
t.ParseFromString(payload)
|
||||
return t
|
||||
except:
|
||||
return None
|
||||
|
||||
def generate_secret(self, item: Item, variation: ItemVariation = None, subevent: SubEvent = None,
|
||||
current_secret: str = None, force_invalidate=False):
|
||||
if current_secret and not force_invalidate:
|
||||
ticket = self._parse(current_secret)
|
||||
if ticket:
|
||||
unchanged = (
|
||||
ticket.item == item.pk and
|
||||
ticket.variation == (variation.pk if variation else 0) and
|
||||
ticket.subevent == (subevent.pk if subevent else 0)
|
||||
)
|
||||
if unchanged:
|
||||
return current_secret
|
||||
|
||||
t = pretix_sig1_pb2.Ticket()
|
||||
t.seed = get_random_string(9)
|
||||
t.item = item.pk
|
||||
t.variation = variation.pk if variation else 0
|
||||
t.subevent = subevent.pk if subevent else 0
|
||||
payload = t.SerializeToString()
|
||||
result = base64.b64encode(self._sign_payload(payload)).decode()[::-1]
|
||||
return result
|
||||
|
||||
|
||||
@receiver(register_ticket_secret_generators, dispatch_uid="ticket_generator_default")
|
||||
def recv_classic(sender, **kwargs):
|
||||
return [RandomTicketSecretGenerator, Sig1TicketSecretGenerator]
|
||||
|
||||
|
||||
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True):
|
||||
gen = event.ticket_secret_generator
|
||||
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
|
||||
force_invalidate = True
|
||||
secret = gen.generate_secret(
|
||||
item=position.item,
|
||||
variation=position.variation,
|
||||
subevent=position.subevent,
|
||||
current_secret=position.secret,
|
||||
force_invalidate=force_invalidate
|
||||
)
|
||||
changed = position.secret != secret
|
||||
if position.secret and changed and gen.use_revocation_list:
|
||||
position.revoked_secrets.create(event=event, secret=position.secret)
|
||||
position.secret = secret
|
||||
if save and changed:
|
||||
position.save()
|
||||
@@ -32,13 +32,13 @@ from pretix.base.models import (
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.items import ItemBundle
|
||||
from pretix.base.models.orders import (
|
||||
InvoiceAddress, OrderFee, OrderRefund, generate_position_secret,
|
||||
generate_secret,
|
||||
InvoiceAddress, OrderFee, OrderRefund, generate_secret,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
@@ -371,7 +371,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
position.canceled = True
|
||||
position.save(update_fields=['canceled'])
|
||||
assign_ticket_secret(
|
||||
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
||||
)
|
||||
position.save(update_fields=['canceled', 'secret'])
|
||||
new_fee = cancellation_fee
|
||||
for fee in order.fees.all():
|
||||
if keep_fees and fee in keep_fees:
|
||||
@@ -406,6 +409,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
order.save(update_fields=['status', 'cancellation_date'])
|
||||
|
||||
for position in order.positions.all():
|
||||
assign_ticket_secret(
|
||||
event=order.event, position=position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=True
|
||||
)
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
@@ -1564,6 +1570,9 @@ class OrderChangeManager:
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SeatOperation):
|
||||
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
|
||||
@@ -1575,6 +1584,9 @@ class OrderChangeManager:
|
||||
'new_seat_id': op.seat.pk if op.seat else None,
|
||||
})
|
||||
op.position.seat = op.seat
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
op.position.save()
|
||||
elif isinstance(op, self.SubeventOperation):
|
||||
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
|
||||
@@ -1586,7 +1598,9 @@ class OrderChangeManager:
|
||||
'new_price': op.position.price
|
||||
})
|
||||
op.position.subevent = op.subevent
|
||||
op.position.save()
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=False, save=False
|
||||
)
|
||||
if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id:
|
||||
op.position.price_before_voucher = max(
|
||||
op.position.price,
|
||||
@@ -1597,6 +1611,7 @@ class OrderChangeManager:
|
||||
invoice_address=self._invoice_address
|
||||
).gross
|
||||
)
|
||||
op.position.save()
|
||||
elif isinstance(op, self.AddFeeOperation):
|
||||
self.order.log_action('pretix.event.order.changed.addfee', user=self.user, auth=self.auth, data={
|
||||
'fee': op.fee.pk,
|
||||
@@ -1675,7 +1690,10 @@ class OrderChangeManager:
|
||||
opa.canceled = True
|
||||
if opa.voucher:
|
||||
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
opa.save(update_fields=['canceled'])
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
||||
)
|
||||
opa.save(update_fields=['canceled', 'secret'])
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
@@ -1687,7 +1705,10 @@ class OrderChangeManager:
|
||||
op.position.canceled = True
|
||||
if op.position.voucher:
|
||||
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
op.position.save(update_fields=['canceled'])
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate_if_revokation_list_used=True, force_invalidate=False, save=False
|
||||
)
|
||||
op.position.save(update_fields=['canceled', 'secret'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
@@ -1709,8 +1730,9 @@ class OrderChangeManager:
|
||||
elif isinstance(op, self.SplitOperation):
|
||||
split_positions.append(op.position)
|
||||
elif isinstance(op, self.RegenerateSecretOperation):
|
||||
op.position.secret = generate_position_secret()
|
||||
op.position.save()
|
||||
assign_ticket_secret(
|
||||
event=self.event, position=op.position, force_invalidate=True, save=True
|
||||
)
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
|
||||
'order': self.order.pk})
|
||||
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
|
||||
@@ -1743,7 +1765,9 @@ class OrderChangeManager:
|
||||
'new_order': split_order.code,
|
||||
})
|
||||
op.order = split_order
|
||||
op.secret = generate_position_secret()
|
||||
assign_ticket_secret(
|
||||
self.event, position=op, force_invalidate=True,
|
||||
)
|
||||
op.save()
|
||||
|
||||
try:
|
||||
|
||||
@@ -374,6 +374,10 @@ DEFAULTS = {
|
||||
'default': 'classic',
|
||||
'type': str,
|
||||
},
|
||||
'ticket_secret_generator': {
|
||||
'default': 'random',
|
||||
'type': str,
|
||||
},
|
||||
'reservation_time': {
|
||||
'default': '30',
|
||||
'type': int,
|
||||
|
||||
@@ -216,6 +216,16 @@ subclass of pretix.base.invoice.BaseInvoiceRenderer or a list of these
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_ticket_secret_generators = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known ticket secret generators. Receivers should return a
|
||||
subclass of ``pretix.base.secrets.BaseTicketSecretGenerator`` or a list of these
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_data_shredders = EventPluginSignal(
|
||||
providing_args=[]
|
||||
)
|
||||
|
||||
@@ -1069,6 +1069,20 @@ class TicketSettingsForm(SettingsForm):
|
||||
'ticket_download_pending',
|
||||
'ticket_download_require_validated_email',
|
||||
]
|
||||
ticket_secret_generator = forms.ChoiceField(
|
||||
label=_("Ticket code generator"),
|
||||
help_text=_("For advanced users, usually does not need to be changed."),
|
||||
required=True,
|
||||
widget=forms.RadioSelect,
|
||||
choices=[]
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
event = kwargs.get('obj')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['ticket_secret_generator'].choices = [
|
||||
(r.identifier, r.verbose_name) for r in event.ticket_secret_generators.values()
|
||||
]
|
||||
|
||||
def prepare_fields(self):
|
||||
# See clean()
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
<legend>{% trans "Download time" %}</legend>
|
||||
{% bootstrap_field form.ticket_download_date layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Ticket codes" %}</legend>
|
||||
{% bootstrap_field form.ticket_secret_generator layout="control" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -40,13 +40,14 @@ from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
|
||||
InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
|
||||
Quota, generate_position_secret, generate_secret,
|
||||
Quota, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
|
||||
)
|
||||
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.secrets import assign_ticket_secret
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.cancelevent import cancel_event
|
||||
from pretix.base.services.export import export
|
||||
@@ -1644,8 +1645,9 @@ class OrderContactChange(OrderView):
|
||||
changed = True
|
||||
self.order.secret = generate_secret()
|
||||
for op in self.order.all_positions.all():
|
||||
op.secret = generate_position_secret()
|
||||
op.save()
|
||||
assign_ticket_secret(
|
||||
self.request.event, position=op, force_invalidate=True, save=True
|
||||
)
|
||||
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'order': self.order.pk})
|
||||
self.order.log_action('pretix.event.order.secret.changed', user=self.request.user)
|
||||
|
||||
|
||||
@@ -732,7 +732,7 @@ $(function () {
|
||||
|
||||
$("button[data-toggle=qrcode]").click(function (e) {
|
||||
e.preventDefault();
|
||||
var $current = $(".qr-code-overlay[data-qrcode=" + $(this).attr("data-qrcode") + "]");
|
||||
var $current = $(".qr-code-overlay[data-qrcode='" + $(this).attr("data-qrcode") + "']");
|
||||
if ($current.length) {
|
||||
$(".qr-code-overlay").attr("data-qrcode", "").slideUp(200);
|
||||
return false;
|
||||
|
||||
@@ -68,3 +68,5 @@ arabic-reshaper==2.0.15 # Support for Aabic in reportlab
|
||||
packaging
|
||||
tlds>=2020041600
|
||||
text-unidecode==1.*
|
||||
protobuf==3.13.*
|
||||
cryptography
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[flake8]
|
||||
ignore = N802,W503,E402,C901,E722,W504,E252,N812,N806
|
||||
max-line-length = 160
|
||||
exclude = migrations,.ropeproject,static,mt940.py,_static,build,make_testdata.py,*/testutils/settings.py,tests/settings.py,pretix/base/models/__init__.py
|
||||
exclude = migrations,.ropeproject,static,mt940.py,_static,build,make_testdata.py,*/testutils/settings.py,tests/settings.py,pretix/base/models/__init__.py,pretix/base/secretgenerators/pretix_sig1_pb2.py
|
||||
max-complexity = 11
|
||||
|
||||
[isort]
|
||||
|
||||
+3
-1
@@ -154,7 +154,9 @@ setup(
|
||||
'arabic-reshaper==2.0.15', # Support for Arabic in reportlab
|
||||
'packaging',
|
||||
'tlds>=2020041600',
|
||||
'text-unidecode==1.*'
|
||||
'text-unidecode==1.*',
|
||||
'protobuf==3.13.*',
|
||||
'cryptography',
|
||||
],
|
||||
extras_require={
|
||||
'dev': [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from unittest import mock
|
||||
@@ -153,8 +154,10 @@ def test_event_list_filter(token_client, organizer, event):
|
||||
@pytest.mark.django_db
|
||||
def test_event_get(token_client, organizer, event):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
|
||||
res = copy.copy(TEST_EVENT_RES)
|
||||
res["valid_keys"] = {"pretix_sig1": []}
|
||||
assert resp.status_code == 200
|
||||
assert TEST_EVENT_RES == resp.data
|
||||
assert res == resp.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -646,15 +649,6 @@ def test_event_update_plugins(token_client, organizer, event, free_item, free_qu
|
||||
assert resp.content.decode() == '{"plugins":["Unknown plugin: \'pretix.plugins.test\'."]}'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_event_detail(token_client, organizer, event, team):
|
||||
team.all_events = True
|
||||
team.save()
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
|
||||
assert resp.status_code == 200
|
||||
assert TEST_EVENT_RES == resp.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_event_delete(token_client, organizer, event):
|
||||
resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
|
||||
|
||||
@@ -4552,3 +4552,18 @@ def test_orderposition_price_calculation_reverse_charge(token_client, organizer,
|
||||
'tax_rule': taxrule.pk,
|
||||
'tax': Decimal('0.00')
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_revoked_secret_list(token_client, organizer, event):
|
||||
r = event.revoked_secrets.create(secret="abcd")
|
||||
res = {
|
||||
"id": r.id,
|
||||
"secret": "abcd",
|
||||
"created": r.created.isoformat().replace("+00:00", "Z")
|
||||
}
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/revokedsecrets/'.format(
|
||||
organizer.slug, event.slug,
|
||||
))
|
||||
assert resp.status_code == 200
|
||||
assert [res] == resp.data['results']
|
||||
|
||||
@@ -25,6 +25,8 @@ event_urls = [
|
||||
event_permission_sub_urls = [
|
||||
('get', 'can_change_event_settings', 'settings/', 200),
|
||||
('patch', 'can_change_event_settings', 'settings/', 200),
|
||||
('get', 'can_view_orders', 'revokedsecrets/', 200),
|
||||
('get', 'can_view_orders', 'revokedsecrets/1/', 404),
|
||||
('get', 'can_view_orders', 'orders/', 200),
|
||||
('get', 'can_view_orders', 'orderpositions/', 200),
|
||||
('delete', 'can_change_orders', 'orderpositions/1/', 404),
|
||||
|
||||
@@ -675,6 +675,24 @@ class OrderCancelTests(TestCase):
|
||||
assert self.order.invoices.count() == 3
|
||||
assert not self.order.invoices.last().is_cancellation
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_paid_with_fee_change_secret(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
s = self.op1.secret
|
||||
self.order.fees.create(fee_type=OrderFee.FEE_TYPE_SHIPPING, value=2.5)
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.total = 48.5
|
||||
self.order.save()
|
||||
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5)
|
||||
self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1)
|
||||
self.op1.save()
|
||||
cancel_order(self.order.pk, cancellation_fee=2.5)
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.status == Order.STATUS_PAID
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
assert self.op1.secret != s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_auto_refund_possible(self):
|
||||
p1 = self.order.payments.create(
|
||||
@@ -857,6 +875,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
def test_change_subevent_success(self):
|
||||
self.event.has_subevents = True
|
||||
self.event.save()
|
||||
s = self.op1.secret
|
||||
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||
se2 = self.event.subevents.create(name="Bar", date_from=now())
|
||||
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12)
|
||||
@@ -872,6 +891,30 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.subevent == se2
|
||||
assert self.op1.price == Decimal('23.00')
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
assert self.op1.secret == s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_success_change_secret(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
self.event.has_subevents = True
|
||||
self.event.save()
|
||||
se1 = self.event.subevents.create(name="Foo", date_from=now())
|
||||
se2 = self.event.subevents.create(name="Bar", date_from=now())
|
||||
SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12)
|
||||
s = self.op1.secret
|
||||
self.op1.subevent = se1
|
||||
self.op1.save()
|
||||
self.quota.subevent = se2
|
||||
self.quota.save()
|
||||
|
||||
self.ocm.change_subevent(self.op1, se2)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.op1.subevent == se2
|
||||
assert self.op1.price == Decimal('23.00')
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
assert self.op1.secret != s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_with_price_success(self):
|
||||
@@ -919,7 +962,9 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_keep_price(self):
|
||||
def test_change_new_secret_by_scheme(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
s = self.op1.secret
|
||||
p = self.op1.price
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit()
|
||||
@@ -929,6 +974,21 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.price == p
|
||||
assert self.op1.tax_value == Decimal('3.67')
|
||||
assert self.op1.tax_rule == self.shirt.tax_rule
|
||||
assert self.op1.secret != s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_keep_price(self):
|
||||
p = self.op1.price
|
||||
s = self.op1.secret
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
self.order.refresh_from_db()
|
||||
assert self.op1.item == self.shirt
|
||||
assert self.op1.price == p
|
||||
assert self.op1.tax_value == Decimal('3.67')
|
||||
assert self.op1.tax_rule == self.shirt.tax_rule
|
||||
assert self.op1.secret == s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_change_price_before_voucher(self):
|
||||
@@ -1011,6 +1071,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_success(self):
|
||||
s = self.op1.secret
|
||||
self.ocm.cancel(self.op1)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
@@ -1018,6 +1079,20 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.order.total == self.op2.price
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
assert self.op1.secret == s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_success_changed_secret(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
s = self.op1.secret
|
||||
self.ocm.cancel(self.op1)
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.positions.count() == 1
|
||||
assert self.order.total == self.op2.price
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
assert self.op1.secret != s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_with_addon(self):
|
||||
@@ -2278,6 +2353,9 @@ class OrderChangeManagerTests(TestCase):
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_clear_out_order(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
op = self.order.positions.first()
|
||||
s = op.secret
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
@@ -2290,6 +2368,27 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('0.00')
|
||||
assert self.order.status == Order.STATUS_CANCELED
|
||||
assert op.secret == s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_clear_out_order_change_secrets(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
op = self.order.positions.first()
|
||||
s = op.secret
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.payments.create(amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
provider='manual')
|
||||
cancel_order(self.order, cancellation_fee=Decimal('5.00'))
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('5.00')
|
||||
self.ocm.cancel_fee(self.order.fees.get())
|
||||
self.ocm.commit()
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('0.00')
|
||||
assert self.order.status == Order.STATUS_CANCELED
|
||||
op.refresh_from_db()
|
||||
assert op.secret != s
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_auto_change_payment_fee(self):
|
||||
|
||||
Reference in New Issue
Block a user