Add pluggable ticket secret generators (#1809)

This commit is contained in:
Raphael Michel
2020-10-19 15:00:55 +02:00
committed by GitHub
parent 6e20f33ef5
commit 22bba28bea
43 changed files with 890 additions and 69 deletions
+1 -1
View File
@@ -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
View File
@@ -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
-----------------------
+1 -1
View File
@@ -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
+14 -2
View File
@@ -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
+54
View File
@@ -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.
+2 -1
View File
@@ -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
""""""""""""
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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
+93
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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'),
)
+23 -1
View File
@@ -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)
+8 -1
View File
@@ -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')
+1
View File
@@ -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)
+13 -2
View File
@@ -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
-1
View File
@@ -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'
)
+31 -4
View File
@@ -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),
),
]
+32
View File
@@ -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.
+22 -6
View File
@@ -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:
+9 -2
View 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)
+202
View File
@@ -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()
+33 -9
View File
@@ -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:
+4
View File
@@ -374,6 +374,10 @@ DEFAULTS = {
'default': 'classic',
'type': str,
},
'ticket_secret_generator': {
'default': 'random',
'type': str,
},
'reservation_time': {
'default': '30',
'type': int,
+10
View File
@@ -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=[]
)
+14
View File
@@ -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">
+5 -3
View File
@@ -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;
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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': [
+4 -10
View File
@@ -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))
+15
View File
@@ -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']
+2
View File
@@ -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),
+100 -1
View File
@@ -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):