forked from CGM_Public/pretix_original
Compare commits
41 Commits
stable
...
searchinde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173cf751da | ||
|
|
b3debdfb55 | ||
|
|
abb770a8e7 | ||
|
|
72a2d0da35 | ||
|
|
937cec53f7 | ||
|
|
6e4af5da64 | ||
|
|
7ed35e06ba | ||
|
|
55841ea660 | ||
|
|
78544cdb30 | ||
|
|
37183aced7 | ||
|
|
a7d3cb134c | ||
|
|
da8f7f163f | ||
|
|
89d612beed | ||
|
|
f23de7e2c0 | ||
|
|
d073007fd7 | ||
|
|
d9d1c83218 | ||
|
|
ae9b8bafb8 | ||
|
|
cbf5c2ec1d | ||
|
|
17392f3ef4 | ||
|
|
bf36ad009f | ||
|
|
ca9e4823e2 | ||
|
|
d505422e0f | ||
|
|
33c43ce482 | ||
|
|
f273cf4960 | ||
|
|
afdf09eeb4 | ||
|
|
01e5872f61 | ||
|
|
14cc31c810 | ||
|
|
2972129547 | ||
|
|
ec4227651a | ||
|
|
77950de588 | ||
|
|
187576eee5 | ||
|
|
0e513a0985 | ||
|
|
1cde728ffe | ||
|
|
76893caffc | ||
|
|
a539999c04 | ||
|
|
b9c570b3d8 | ||
|
|
48b399424a | ||
|
|
1c73f000a9 | ||
|
|
d0721165c1 | ||
|
|
bed0a0ceeb | ||
|
|
b53ee1dc1d |
@@ -125,6 +125,23 @@ Example::
|
||||
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
|
||||
turns on some optimizations/special case handlers. Default: ``False``
|
||||
|
||||
Database replica settings
|
||||
-------------------------
|
||||
|
||||
If you use a replicated database setup, pretix expects that the default database connection always points to the primary database node.
|
||||
Routing read queries to a replica on databse layer is **strongly** discouraged since this can lead to inaccurate such as more tickets
|
||||
being sold than are actually available.
|
||||
|
||||
However, pretix can still make use of a database replica to keep some expensive queries with that can tolerate some latency from your
|
||||
primary database, such as backend search queries. The ``replica`` configuration section can have the same settings as the ``database``
|
||||
section (except for the ``backend`` setting) and will default back to the ``database`` settings for all values that are not given. This
|
||||
way, you just need to specify the settings that are different for the replica.
|
||||
|
||||
Example::
|
||||
|
||||
[replica]
|
||||
host=192.168.0.2
|
||||
|
||||
URLs
|
||||
----
|
||||
|
||||
|
||||
@@ -565,7 +565,10 @@ Order position endpoints
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
"status": "ok",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
@@ -576,7 +579,10 @@ Order position endpoints
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"status": "incomplete",
|
||||
"position": {
|
||||
…
|
||||
},
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -621,6 +627,9 @@ Order position endpoints
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
"position": {
|
||||
…
|
||||
}
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.4.0"
|
||||
__version__ = "2.5.0.dev0"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
@@ -108,7 +109,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
plugins = validated_data.pop('plugins', None)
|
||||
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
|
||||
event = super().create(validated_data)
|
||||
|
||||
# Meta data
|
||||
@@ -122,6 +123,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
# Plugins
|
||||
if plugins is not None:
|
||||
event.set_active_plugins(plugins)
|
||||
event.save(update_fields=['plugins'])
|
||||
|
||||
return event
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, Event, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
)
|
||||
@@ -201,12 +203,39 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
subevent=self.checkinlist.subevent
|
||||
).annotate(
|
||||
last_checked_in=Subquery(cqs)
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
).select_related('item', 'variation', 'order', 'addon_to')
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id=self.checkinlist.pk)
|
||||
),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
|
||||
|
||||
if not self.checkinlist.all_products:
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
@@ -251,6 +280,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
@@ -259,10 +290,14 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code,
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
|
||||
'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
|
||||
@@ -26,8 +26,8 @@ from pretix.api.serializers.order import (
|
||||
OrderRefundSerializer, OrderSerializer,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Device, Invoice, Order, OrderPayment,
|
||||
OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
|
||||
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.payment import PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
@@ -83,6 +83,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
'positions',
|
||||
OrderPosition.objects.all().prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
'item__category', 'addon_to',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
)
|
||||
)
|
||||
@@ -391,6 +392,7 @@ class OrderPositionFilter(FilterSet):
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
@@ -435,11 +437,33 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
qs = OrderPosition.objects.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false') == 'true':
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
Event.objects.select_related('organizer')
|
||||
),
|
||||
Prefetch(
|
||||
'positions',
|
||||
OrderPosition.objects.prefetch_related(
|
||||
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
'checkins', 'answers', 'answers__options', 'answers__question'
|
||||
).select_related(
|
||||
'item', 'order', 'order__event', 'order__event__organizer'
|
||||
)
|
||||
return qs
|
||||
|
||||
def _get_output_provider(self, identifier):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
|
||||
@@ -209,7 +209,7 @@ class MultiSheetListExporter(ListExporter):
|
||||
sheet, f = form_data.get('_format').split(':')
|
||||
if f == 'default':
|
||||
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||
elif f == 'csv-excel':
|
||||
elif f == 'excel':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel')
|
||||
elif f == 'semicolon':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')
|
||||
|
||||
@@ -559,6 +559,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
_('Tax value'),
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
_('Event start date'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
@@ -598,6 +599,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
|
||||
l.tax_value,
|
||||
l.tax_rate,
|
||||
l.tax_name,
|
||||
date_format(l.event_date_from, "SHORT_DATE_FORMAT") if l.event_date_from else "",
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
|
||||
@@ -90,6 +90,8 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs = dict(attrs)
|
||||
date_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
time_attrs.setdefault('class', 'form-control splitdatetimepart')
|
||||
date_attrs.setdefault('autocomplete', 'off')
|
||||
time_attrs.setdefault('autocomplete', 'off')
|
||||
date_attrs['class'] += ' datepickerfield'
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
|
||||
|
||||
30
src/pretix/base/management/commands/rebuild_search_index.py
Normal file
30
src/pretix/base/management/commands/rebuild_search_index.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from pretix.base.models import Order, OrderSearchIndex
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild search index"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--clean', action='store_true', dest='clean',
|
||||
help="Clear search index before run.",
|
||||
)
|
||||
|
||||
def iter_pages(self, qs):
|
||||
paginator = Paginator(qs, 500)
|
||||
for index in range(paginator.num_pages):
|
||||
yield paginator.get_page(index + 1)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options.get('clean'):
|
||||
OrderSearchIndex.objects.all().delete()
|
||||
qs = Order.objects.select_related('event', 'event__organizer', 'invoice_address').prefetch_related('all_positions', 'payments')
|
||||
for page in self.iter_pages(qs):
|
||||
if options.get('clean'):
|
||||
OrderSearchIndex.objects.bulk_create([o.index() for o in page])
|
||||
else:
|
||||
for o in page:
|
||||
o.index()
|
||||
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
27
src/pretix/base/migrations/0109_auto_20190208_1432.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-02-08 14:32
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0108_auto_20190201_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='event_date_from',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceline',
|
||||
name='subevent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
|
||||
),
|
||||
]
|
||||
49
src/pretix/base/migrations/0110_auto_20190214_1041.py
Normal file
49
src/pretix/base/migrations/0110_auto_20190214_1041.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 2.1 on 2019-02-14 10:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def create_extension(apps, schema_editor):
|
||||
if 'postgresql' in schema_editor.connection.settings_dict['ENGINE']:
|
||||
schema_editor.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
|
||||
|
||||
def create_trigram_index(apps, schema_editor):
|
||||
if 'postgresql' in schema_editor.connection.settings_dict['ENGINE']:
|
||||
schema_editor.execute("CREATE INDEX pretixbase_ordersearchindex_s ON pretixbase_ordersearchindex USING gin (search_body gin_trgm_ops);")
|
||||
schema_editor.execute("CREATE INDEX pretixbase_ordersearchindex_spp ON pretixbase_ordersearchindex USING gin (payment_providers gin_trgm_ops);")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0109_auto_20190208_1432'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_extension),
|
||||
migrations.CreateModel(
|
||||
name='OrderSearchIndex',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('search_body', models.TextField()),
|
||||
('payment_providers', models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ordersearchindex',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ordersearchindex',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order', unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ordersearchindex',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.RunPython(create_trigram_index)
|
||||
]
|
||||
@@ -7,6 +7,7 @@ from .event import (
|
||||
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
|
||||
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
|
||||
)
|
||||
from .index import OrderSearchIndex
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
|
||||
|
||||
11
src/pretix/base/models/index.py
Normal file
11
src/pretix/base/models/index.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.db import models
|
||||
|
||||
DELIMITER = "\x1F"
|
||||
|
||||
|
||||
class OrderSearchIndex(models.Model):
|
||||
order = models.ForeignKey('Order', unique=True, null=False, on_delete=models.CASCADE)
|
||||
event = models.ForeignKey('Event', null=False, on_delete=models.CASCADE)
|
||||
organizer = models.ForeignKey('Organizer', null=False, on_delete=models.CASCADE)
|
||||
search_body = models.TextField()
|
||||
payment_providers = models.TextField()
|
||||
@@ -212,6 +212,10 @@ class InvoiceLine(models.Model):
|
||||
:type tax_rate: decimal.Decimal
|
||||
:param tax_name: The name of the applied tax rate
|
||||
:type tax_name: str
|
||||
:param subevent: The subevent this line refers to
|
||||
:type subevent: SubEvent
|
||||
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||
:type event_date_from: datetime
|
||||
"""
|
||||
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
@@ -220,6 +224,8 @@ class InvoiceLine(models.Model):
|
||||
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_name = models.CharField(max_length=190)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
|
||||
@@ -693,6 +693,45 @@ class Order(LockModel, LoggedModel):
|
||||
continue
|
||||
yield op
|
||||
|
||||
def index(self, save=True):
|
||||
from .index import OrderSearchIndex
|
||||
indexed_strings = [
|
||||
self.code,
|
||||
self.full_code,
|
||||
self.email,
|
||||
self.comment,
|
||||
]
|
||||
try:
|
||||
indexed_strings.append(self.invoice_address.name_cached)
|
||||
indexed_strings.append(self.invoice_address.company)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
for p in self.all_positions.all():
|
||||
indexed_strings.append(p.attendee_name_cached)
|
||||
indexed_strings.append(p.attendee_email)
|
||||
indexed_strings.append(p.secret)
|
||||
pprovs = set()
|
||||
for p in self.payments.all():
|
||||
pprovs.add(p.provider)
|
||||
if save:
|
||||
return OrderSearchIndex.objects.update_or_create(
|
||||
order=self,
|
||||
defaults={
|
||||
'event': self.event,
|
||||
'organizer': self.event.organizer,
|
||||
'search_body': '\x1E'.join([str(v) for v in indexed_strings if v]),
|
||||
'payment_providers': '\x1E' + '\x1E'.join([str(v) for v in pprovs if v]) + '\x1E',
|
||||
}
|
||||
)[0]
|
||||
else:
|
||||
return OrderSearchIndex(
|
||||
order=self,
|
||||
event=self.event,
|
||||
organizer=self.event.organizer,
|
||||
search_body='\x1E'.join([str(v) for v in indexed_strings if v]),
|
||||
payment_providers='\x1E' + '\x1E'.join([str(v) for v in pprovs if v]) + '\x1E',
|
||||
)
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -1098,9 +1137,23 @@ class OrderPayment(models.Model):
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
self.state = self.PAYMENT_STATE_CONFIRMED
|
||||
self.payment_date = now()
|
||||
self.save()
|
||||
with transaction.atomic():
|
||||
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
|
||||
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
|
||||
# Race condition detected, this payment is already confirmed
|
||||
return
|
||||
|
||||
locked_instance.state = self.PAYMENT_STATE_CONFIRMED
|
||||
locked_instance.payment_date = now()
|
||||
locked_instance.info = self.info # required for backwards compatibility
|
||||
locked_instance.save(update_fields=['state', 'payment_date', 'info'])
|
||||
|
||||
# Do a cheap manual "refresh from db" on non-complex fields
|
||||
for field in self._meta.concrete_fields:
|
||||
if not field.is_relation:
|
||||
setattr(self, field.attname, getattr(locked_instance, field.attname))
|
||||
|
||||
self.refresh_from_db()
|
||||
|
||||
self.order.log_action('pretix.event.order.payment.confirmed', {
|
||||
'local_id': self.local_id,
|
||||
|
||||
@@ -788,9 +788,9 @@ class ManualPayment(BasePaymentProvider):
|
||||
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||
return msg
|
||||
|
||||
def order_pending_render(self, request, order) -> str:
|
||||
def payment_pending_render(self, request, payment) -> str:
|
||||
return rich_text(
|
||||
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
|
||||
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ class CartManager:
|
||||
# could cancel each other out quota-wise). However, we are not taking this performance
|
||||
# penalty for now as there is currently no outside interface that would allow building
|
||||
# such a transaction.
|
||||
for cp in self.positions.all():
|
||||
for cp in self.positions.filter(addon_to__isnull=True):
|
||||
self._operations.append(self.RemoveOperation(position=cp))
|
||||
|
||||
def set_addons(self, addons):
|
||||
@@ -653,6 +653,7 @@ class CartManager:
|
||||
op.position.price = op.price.gross
|
||||
op.position.save()
|
||||
elif available_count == 0:
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
else:
|
||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||
|
||||
@@ -142,6 +142,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
InvoiceLine.objects.create(
|
||||
position=i, invoice=invoice, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
subevent=p.subevent, event_date_from=(p.subevent.date_from if p.subevent else invoice.event.date_from),
|
||||
tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Max, Q, Sum
|
||||
from django.db.models.functions import Greatest
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import cached_property
|
||||
@@ -255,7 +256,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
if send_mail:
|
||||
try:
|
||||
@@ -323,7 +324,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
with order.event.lock():
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
position.canceled = True
|
||||
position.save(update_fields=['canceled'])
|
||||
for fee in order.fees.all():
|
||||
@@ -354,7 +355,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
||||
data={'cancellation_fee': cancellation_fee})
|
||||
@@ -814,12 +815,17 @@ class OrderChangeManager:
|
||||
self.notify = notify
|
||||
self._invoice_dirty = False
|
||||
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
|
||||
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
|
||||
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
|
||||
raise OrderError(self.error_messages['product_without_variation'])
|
||||
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
if keep_price:
|
||||
price = TaxedPrice(gross=position.price, net=position.price - position.tax_value,
|
||||
tax=position.tax_value, rate=position.tax_rate,
|
||||
name=position.tax_rule.name)
|
||||
else:
|
||||
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
|
||||
invoice_address=self._invoice_address)
|
||||
|
||||
if price is None: # NOQA
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
@@ -1067,7 +1073,7 @@ class OrderChangeManager:
|
||||
})
|
||||
opa.canceled = True
|
||||
if opa.voucher:
|
||||
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
opa.save(update_fields=['canceled'])
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
@@ -1079,7 +1085,7 @@ class OrderChangeManager:
|
||||
})
|
||||
op.position.canceled = True
|
||||
if op.position.voucher:
|
||||
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
op.position.save(update_fields=['canceled'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
pos = OrderPosition.objects.create(
|
||||
@@ -1300,7 +1306,7 @@ class OrderChangeManager:
|
||||
except SendMailException:
|
||||
logger.exception('Order changed email could not be sent')
|
||||
|
||||
def commit(self):
|
||||
def commit(self, check_quotas=True):
|
||||
if self._committed:
|
||||
# an order change can only be committed once
|
||||
raise OrderError(error_messages['internal'])
|
||||
@@ -1317,7 +1323,8 @@ class OrderChangeManager:
|
||||
with self.order.event.lock():
|
||||
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
raise OrderError(self.error_messages['not_pending_or_paid'])
|
||||
self._check_quotas()
|
||||
if check_quotas:
|
||||
self._check_quotas()
|
||||
self._check_complete_cancel()
|
||||
self._perform_operations()
|
||||
self._recalculate_total_and_payment_fee()
|
||||
@@ -1409,7 +1416,7 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
else:
|
||||
elif refund_amount != Decimal('0.00'):
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
|
||||
@@ -117,7 +117,7 @@ def get_tickets_for_order(order):
|
||||
|
||||
if p.multi_download_enabled:
|
||||
try:
|
||||
if len(order.positions_with_tickets) == 0:
|
||||
if len(list(order.positions_with_tickets)) == 0:
|
||||
continue
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=p.identifier, file__isnull=False
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if request.sentry.id %}
|
||||
{% if sentry_event_id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ request.sentry.id }}
|
||||
{{ sentry_event_id }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.template.loader import get_template
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from sentry_sdk import last_event_id
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
@@ -65,5 +66,6 @@ def server_error(request):
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
|
||||
@@ -207,10 +207,11 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
('cp', _('Canceled (or with paid fee)')),
|
||||
('pa', _('Approval pending')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('underpaid', _('Underpaid')),
|
||||
('pendingpaid', _('Pending (but fully paid)'))
|
||||
('pendingpaid', _('Pending (but fully paid)')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -244,10 +245,10 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False)
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False)
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
|
||||
|
||||
if fdata.get('question') and fdata.get('answer') is not None:
|
||||
q = fdata.get('question')
|
||||
@@ -297,6 +298,15 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
status=Order.STATUS_PENDING,
|
||||
require_approval=True
|
||||
)
|
||||
elif fdata.get('status') == 'cp':
|
||||
s = OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
qs = qs.annotate(
|
||||
has_pc=Exists(s)
|
||||
).filter(
|
||||
Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED)
|
||||
)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
@@ -177,6 +177,10 @@ class OtherOperationsForm(forms.Form):
|
||||
'Send an email to the customer notifying that their order has been changed.'
|
||||
)
|
||||
)
|
||||
ignore_quotas = forms.BooleanField(
|
||||
label=_('Allow to overbook quotas when performing this operation'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('order')
|
||||
@@ -286,6 +290,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
('secret', 'Regenerate secret'),
|
||||
)
|
||||
)
|
||||
change_product_keep_price = forms.BooleanField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.pop('instance')
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<span class="fa fa-download"></span>
|
||||
{% trans "PDF" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlistcsv&checkinlistcsv-list={{ checkinlist.pk }}"
|
||||
<a href="{% url "control:event.orders.export" event=request.event.slug organizer=request.event.organizer.slug %}?identifier=checkinlist&checkinlist-list={{ checkinlist.pk }}"
|
||||
class="btn btn-default" target="_blank">
|
||||
<span class="fa fa-download"></span>
|
||||
{% trans "CSV" %}
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
{% trans "Change product to" %}
|
||||
{% bootstrap_field position.form.itemvar layout='inline' %}
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
{{ position.form.change_product_keep_price }}
|
||||
{% trans "Keep price the same" %}
|
||||
</label>
|
||||
</div>
|
||||
{% if request.event.has_subevents %}
|
||||
<div class="radio">
|
||||
@@ -202,6 +206,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
{% bootstrap_field other_form.ignore_quotas layout="" %}
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<a class="btn btn-default btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th>{% trans "Canceled" %}¹</th>
|
||||
<th>{% trans "Expired" %}</th>
|
||||
<th colspan="3">{% trans "Purchased" %}</th>
|
||||
<th colspan="3" class="text-center">{% trans "Purchased" %}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
|
||||
@@ -135,14 +135,33 @@ class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
template_name = 'pretixcontrol/checkin/lists.html'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.checkin_lists.prefetch_related("limit_products")
|
||||
qs = CheckinList.annotate_with_numbers(qs, self.request.event)
|
||||
qs = self.request.event.checkin_lists.select_related('subevent').prefetch_related("limit_products")
|
||||
|
||||
if self.request.GET.get("subevent", "") != "":
|
||||
s = self.request.GET.get("subevent", "")
|
||||
qs = qs.filter(subevent_id=s)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
clists = list(ctx['checkinlists'])
|
||||
|
||||
# Optimization: Fetch expensive columns for this page only
|
||||
annotations = {
|
||||
a['pk']: a
|
||||
for a in CheckinList.annotate_with_numbers(CheckinList.objects.filter(pk__in=[l.pk for l in clists]), self.request.event).values(
|
||||
'pk', 'checkin_count', 'position_count', 'percent'
|
||||
)
|
||||
}
|
||||
for cl in clists:
|
||||
if cl.subevent:
|
||||
cl.subevent.event = self.request.event # re-use same event object to make sure settings are cached
|
||||
cl.checkin_count = annotations.get(cl.pk, {}).get('checkin_count', 0)
|
||||
cl.position_count = annotations.get(cl.pk, {}).get('position_count', 0)
|
||||
cl.percent_count = annotations.get(cl.pk, {}).get('percent_count', 0)
|
||||
ctx['checkinlists'] = clists
|
||||
return ctx
|
||||
|
||||
|
||||
class CheckinListCreate(EventPermissionRequiredMixin, CreateView):
|
||||
model = CheckinList
|
||||
|
||||
@@ -1229,7 +1229,7 @@ class OrderChange(OrderView):
|
||||
variation = ItemVariation.objects.get(pk=varid, item=item)
|
||||
else:
|
||||
variation = None
|
||||
ocm.change_item(p, item, variation)
|
||||
ocm.change_item(p, item, variation, keep_price=p.form.cleaned_data['change_product_keep_price'])
|
||||
elif p.form.cleaned_data['operation'] == 'price':
|
||||
ocm.change_price(p, p.form.cleaned_data['price'])
|
||||
elif p.form.cleaned_data['operation'] == 'subevent':
|
||||
@@ -1259,7 +1259,7 @@ class OrderChange(OrderView):
|
||||
messages.error(self.request, _('An error occurred. Please see the details below.'))
|
||||
else:
|
||||
try:
|
||||
ocm.commit()
|
||||
ocm.commit(check_quotas=not self.other_form.cleaned_data['ignore_quotas'])
|
||||
except OrderError as e:
|
||||
messages.error(self.request, str(e))
|
||||
else:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, IntegerField, OuterRef, Q, Subquery
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic import ListView
|
||||
@@ -28,7 +29,7 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
annotated = {
|
||||
o['pk']: o
|
||||
for o in
|
||||
Order.objects.filter(
|
||||
Order.objects.using(settings.DATABASE_REPLICA).filter(
|
||||
pk__in=[o.pk for o in ctx['orders']]
|
||||
).annotate(
|
||||
pcnt=Subquery(s, output_field=IntegerField())
|
||||
@@ -45,7 +46,7 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
return ctx
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Order.objects.select_related('invoice_address')
|
||||
qs = Order.objects.select_related('invoice_address').using(settings.DATABASE_REPLICA)
|
||||
|
||||
if not self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||
qs = qs.filter(
|
||||
@@ -60,7 +61,7 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
|
||||
return qs.only(
|
||||
'id', 'invoice_address__name_cached', 'invoice_address__name_parts', 'code', 'event', 'email',
|
||||
'datetime', 'total', 'status'
|
||||
'datetime', 'total', 'status', 'require_approval'
|
||||
).prefetch_related(
|
||||
'event', 'event__organizer'
|
||||
)
|
||||
|
||||
@@ -105,7 +105,10 @@ class VoucherTags(EventPermissionRequiredMixin, TemplateView):
|
||||
redeemed=Sum('redeemed')
|
||||
)
|
||||
for t in tags:
|
||||
t['percentage'] = int((t['redeemed'] / t['total']) * 100)
|
||||
if t['total'] == 0:
|
||||
t['percentage'] = 0
|
||||
else:
|
||||
t['percentage'] = int((t['redeemed'] / t['total']) * 100)
|
||||
|
||||
ctx['tags'] = tags
|
||||
return ctx
|
||||
@@ -140,6 +143,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
|
||||
messages.error(request, _('A voucher can not be deleted if it already has been redeemed.'))
|
||||
else:
|
||||
self.object.log_action('pretix.voucher.deleted', user=self.request.user)
|
||||
self.object.cartposition_set.all().delete()
|
||||
self.object.delete()
|
||||
messages.success(request, _('The selected voucher has been deleted.'))
|
||||
return HttpResponseRedirect(success_url)
|
||||
@@ -344,6 +348,7 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
|
||||
for obj in self.objects:
|
||||
if obj.allow_delete():
|
||||
obj.log_action('pretix.voucher.deleted', user=self.request.user)
|
||||
obj.cartposition_set.all().delete()
|
||||
obj.delete()
|
||||
else:
|
||||
obj.log_action('pretix.voucher.changed', user=self.request.user, data={
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.template.defaultfilters import floatformat
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import pgettext, pgettext_lazy, ugettext as _
|
||||
from reportlab.lib import colors
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.exporter import BaseExporter
|
||||
@@ -162,17 +163,21 @@ class OverviewReport(Report):
|
||||
tstyledata = [
|
||||
('SPAN', (1, 0), (2, 0)),
|
||||
('SPAN', (3, 0), (4, 0)),
|
||||
('SPAN', (5, 0), (6, 0)),
|
||||
('SPAN', (7, 0), (-1, 0)),
|
||||
('SPAN', (5, 0), (-1, 0)),
|
||||
('SPAN', (5, 1), (6, 1)),
|
||||
('SPAN', (7, 1), (8, 1)),
|
||||
('SPAN', (9, 1), (10, 1)),
|
||||
('SPAN', (11, 1), (12, 1)),
|
||||
('ALIGN', (0, 0), (-1, 1), 'CENTER'),
|
||||
('ALIGN', (1, 2), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('FONTNAME', (0, 0), (-1, 1), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 9)
|
||||
('FONTSIZE', (0, 0), (-1, -1), 9),
|
||||
('LINEBEFORE', (1, 0), (1, -1), 1, colors.lightgrey),
|
||||
('LINEBEFORE', (3, 0), (3, -1), 1, colors.lightgrey),
|
||||
('LINEBEFORE', (5, 0), (5, -1), 1, colors.lightgrey),
|
||||
('LINEBEFORE', (7, 1), (7, -1), 1, colors.lightgrey),
|
||||
('LINEBEFORE', (9, 1), (9, -1), 1, colors.lightgrey),
|
||||
]
|
||||
|
||||
story = [
|
||||
@@ -192,7 +197,7 @@ class OverviewReport(Report):
|
||||
'', '', '', '', ''
|
||||
],
|
||||
[
|
||||
'', '', '', '', '', '', '', _('Pending'), '', _('Paid'), '', _('Total'), ''
|
||||
'', '', '', '', '', _('Pending'), '', _('Paid'), '', _('Total'), ''
|
||||
],
|
||||
[
|
||||
'',
|
||||
@@ -201,7 +206,6 @@ class OverviewReport(Report):
|
||||
_('#'), self.event.currency,
|
||||
_('#'), self.event.currency,
|
||||
_('#'), self.event.currency,
|
||||
_('#'), self.event.currency,
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -460,7 +462,7 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
def _total_order_value(self):
|
||||
total = get_cart_total(self.request)
|
||||
total += sum([f.value for f in get_fees(self.request.event, self.request, total, self.invoice_address, None)])
|
||||
return total
|
||||
return Decimal(total)
|
||||
|
||||
@cached_property
|
||||
def provider_forms(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
from itertools import groupby
|
||||
|
||||
@@ -198,7 +199,7 @@ def get_cart_total(request):
|
||||
else:
|
||||
request._cart_total_cache = CartPosition.objects.filter(
|
||||
cart_id=get_or_create_cart_id(request), event=request.event
|
||||
).aggregate(sum=Sum('price'))['sum'] or 0
|
||||
).aggregate(sum=Sum('price'))['sum'] or Decimal('0.00')
|
||||
return request._cart_total_cache
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ def widget_js(request, lang, **kwargs):
|
||||
try:
|
||||
resp = HttpResponse(default_storage.open(fname).read(), content_type='text/javascript')
|
||||
except:
|
||||
logger.exception('Failed to open widget.js')
|
||||
logger.critical('Failed to open widget.js')
|
||||
|
||||
if not resp:
|
||||
data = generate_widget_js(lang).encode()
|
||||
|
||||
@@ -1,50 +1,110 @@
|
||||
from threading import Lock
|
||||
import re
|
||||
import weakref
|
||||
from collections import OrderedDict
|
||||
|
||||
from raven.contrib.celery import SentryCeleryHandler
|
||||
from raven.contrib.django.apps import RavenConfig
|
||||
from raven.contrib.django.models import (
|
||||
SentryDjangoHandler, client, get_client, install_middleware,
|
||||
register_serializers,
|
||||
)
|
||||
from celery.exceptions import Retry
|
||||
from sentry_sdk import Hub
|
||||
from sentry_sdk.integrations.django import DjangoIntegration, _set_user_info
|
||||
from sentry_sdk.utils import capture_internal_exceptions
|
||||
|
||||
_setup_lock = Lock()
|
||||
|
||||
_initialized = False
|
||||
MASK = '*' * 8
|
||||
KEYS = frozenset([
|
||||
'password',
|
||||
'secret',
|
||||
'passwd',
|
||||
'authorization',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'sentry_dsn',
|
||||
'access_token',
|
||||
'session',
|
||||
])
|
||||
VALUES_RE = re.compile(r'^(?:\d[ -]*?){13,16}$')
|
||||
|
||||
|
||||
class CustomSentryDjangoHandler(SentryDjangoHandler):
|
||||
def install_celery(self):
|
||||
self.celery_handler = SentryCeleryHandler(client, ignore_expected=True).install()
|
||||
def scrub_data(data):
|
||||
if isinstance(data, dict):
|
||||
for k, v in data.items():
|
||||
if isinstance(k, bytes):
|
||||
key = k.decode('utf-8', 'replace')
|
||||
else:
|
||||
key = k
|
||||
key = key.lower()
|
||||
data[k] = scrub_data(v)
|
||||
for blk in KEYS:
|
||||
if blk in key:
|
||||
data[k] = MASK
|
||||
elif isinstance(data, list):
|
||||
for i, l in enumerate(list(data)):
|
||||
data[i] = scrub_data(l)
|
||||
elif isinstance(data, str):
|
||||
if '=' in data:
|
||||
# at this point we've assumed it's a standard HTTP query
|
||||
# or cookie
|
||||
if '&' in data:
|
||||
delimiter = '&'
|
||||
else:
|
||||
delimiter = ';'
|
||||
|
||||
qd = scrub_data(OrderedDict(e.split('=', 1) if '=' in e else (e, None) for e in data.split(delimiter)))
|
||||
return delimiter.join((k + '=' + v if v is not None else k) for k, v in qd.items())
|
||||
if VALUES_RE.match(data):
|
||||
return MASK
|
||||
return data
|
||||
|
||||
|
||||
def initialize():
|
||||
global _initialized
|
||||
def _make_event_processor(weak_request, integration):
|
||||
def event_processor(event, hint):
|
||||
request = weak_request()
|
||||
if request is None:
|
||||
return event
|
||||
|
||||
with _setup_lock:
|
||||
if _initialized:
|
||||
return
|
||||
with capture_internal_exceptions():
|
||||
_set_user_info(request, event)
|
||||
request_info = event.setdefault("request", {})
|
||||
request_info["cookies"] = dict(request.COOKIES)
|
||||
|
||||
_initialized = True
|
||||
scrub_data(event.get("request", {}))
|
||||
if 'exception' in event:
|
||||
exc = event.get("exception", {})
|
||||
for val in exc.get('values', []):
|
||||
stack = val.get('stacktrace', {})
|
||||
for frame in stack.get('frames', []):
|
||||
scrub_data(frame['vars'])
|
||||
return event
|
||||
|
||||
try:
|
||||
register_serializers()
|
||||
install_middleware(
|
||||
'raven.contrib.django.middleware.SentryMiddleware',
|
||||
(
|
||||
'raven.contrib.django.middleware.SentryMiddleware',
|
||||
'raven.contrib.django.middleware.SentryLogMiddleware'))
|
||||
install_middleware(
|
||||
'raven.contrib.django.middleware.DjangoRestFrameworkCompatMiddleware')
|
||||
|
||||
handler = CustomSentryDjangoHandler()
|
||||
handler.install()
|
||||
|
||||
# instantiate client so hooks get registered
|
||||
get_client() # NOQA
|
||||
except Exception:
|
||||
_initialized = False
|
||||
return event_processor
|
||||
|
||||
|
||||
class App(RavenConfig):
|
||||
def ready(self):
|
||||
initialize()
|
||||
class PretixSentryIntegration(DjangoIntegration):
|
||||
@staticmethod
|
||||
def setup_once():
|
||||
DjangoIntegration.setup_once()
|
||||
from django.core.handlers.base import BaseHandler
|
||||
|
||||
old_get_response = BaseHandler.get_response
|
||||
|
||||
def sentry_patched_get_response(self, request):
|
||||
hub = Hub.current
|
||||
integration = hub.get_integration(DjangoIntegration)
|
||||
if integration is not None:
|
||||
with hub.configure_scope() as scope:
|
||||
scope.add_event_processor(
|
||||
_make_event_processor(weakref.ref(request), integration)
|
||||
)
|
||||
return old_get_response(self, request)
|
||||
|
||||
BaseHandler.get_response = sentry_patched_get_response
|
||||
|
||||
|
||||
def ignore_retry(event, hint):
|
||||
with capture_internal_exceptions():
|
||||
if isinstance(hint["exc_info"][1], Retry):
|
||||
return None
|
||||
return event
|
||||
|
||||
|
||||
def setup_custom_filters():
|
||||
hub = Hub.current
|
||||
with hub.configure_scope() as scope:
|
||||
scope.add_event_processor(ignore_retry)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from kombu import Queue
|
||||
@@ -90,6 +92,23 @@ DATABASES = {
|
||||
} if 'mysql' in db_backend else {}
|
||||
}
|
||||
}
|
||||
DATABASE_REPLICA = 'default'
|
||||
if config.has_section('replica'):
|
||||
DATABASE_REPLICA = 'replica'
|
||||
DATABASES['replica'] = {
|
||||
'ENGINE': 'django.db.backends.' + db_backend,
|
||||
'NAME': config.get('replica', 'name', fallback=DATABASES['default']['NAME']),
|
||||
'USER': config.get('replica', 'user', fallback=DATABASES['default']['USER']),
|
||||
'PASSWORD': config.get('replica', 'password', fallback=DATABASES['default']['PASSWORD']),
|
||||
'HOST': config.get('replica', 'host', fallback=DATABASES['default']['HOST']),
|
||||
'PORT': config.get('replica', 'port', fallback=DATABASES['default']['PORT']),
|
||||
'CONN_MAX_AGE': 0 if db_backend == 'sqlite3' else 120,
|
||||
'OPTIONS': db_options,
|
||||
'TEST': {
|
||||
'CHARSET': 'utf8mb4',
|
||||
'COLLATION': 'utf8mb4_unicode_ci',
|
||||
} if 'mysql' in db_backend else {}
|
||||
}
|
||||
|
||||
STATIC_URL = config.get('urls', 'static', fallback='/static/')
|
||||
|
||||
@@ -564,15 +583,29 @@ LOGGING = {
|
||||
}
|
||||
|
||||
if config.has_option('sentry', 'dsn'):
|
||||
INSTALLED_APPS += [
|
||||
'pretix.sentry.App',
|
||||
]
|
||||
RAVEN_CONFIG = {
|
||||
'dsn': config.get('sentry', 'dsn'),
|
||||
'transport': 'raven.transport.threaded_requests.ThreadedRequestsHTTPTransport',
|
||||
'release': __version__,
|
||||
'environment': SITE_URL,
|
||||
}
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger
|
||||
from .sentry import PretixSentryIntegration, setup_custom_filters
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=config.get('sentry', 'dsn'),
|
||||
integrations=[
|
||||
PretixSentryIntegration(),
|
||||
CeleryIntegration(),
|
||||
LoggingIntegration(
|
||||
level=logging.INFO,
|
||||
event_level=logging.CRITICAL
|
||||
)
|
||||
],
|
||||
environment=SITE_URL,
|
||||
release=__version__,
|
||||
send_default_pii=False,
|
||||
|
||||
)
|
||||
ignore_logger('pretix.base.tasks')
|
||||
ignore_logger('django.security.DisallowedHost')
|
||||
setup_custom_filters()
|
||||
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
.table-product-overview {
|
||||
|
||||
td:last-child, th:last-child {
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.item.categorized td:first-child {
|
||||
padding-left: 20px;
|
||||
}
|
||||
@@ -9,7 +14,13 @@
|
||||
padding-left: 40px;
|
||||
}
|
||||
td:not(:first-child),
|
||||
th:not(:first-child) {
|
||||
th:not(:first-child):not(.text-center) {
|
||||
border-left: 1px solid #ddd;
|
||||
padding-right: 10px !important;
|
||||
padding-left: 10px !important;
|
||||
}
|
||||
td:not(:first-child),
|
||||
th:not(:first-child):not(.text-center) {
|
||||
text-align: right;
|
||||
}
|
||||
span.sum-net, span.sum-gross {
|
||||
@@ -19,6 +30,14 @@
|
||||
border-top: 2px solid #ddd;
|
||||
}
|
||||
}
|
||||
@media(min-device-width: $screen-lg-min) {
|
||||
.table-product-overview {
|
||||
td:not(:first-child),
|
||||
th:not(:first-child):not(.text-center) {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sumtoggle {
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -29,14 +29,14 @@ csscompressor
|
||||
django-markup
|
||||
markdown<=2.2
|
||||
bleach==2.*
|
||||
raven
|
||||
sentry-sdk==0.7.*
|
||||
babel
|
||||
django-i18nfield>=1.4.0
|
||||
django-hijack>=2.1.10,<2.2.0
|
||||
openpyxl
|
||||
django-oauth-toolkit==1.2.*
|
||||
oauthlib==2.1.*
|
||||
django-jsonfallback
|
||||
django-jsonfallback>=2.1.2
|
||||
psycopg2-binary
|
||||
# Stripe
|
||||
stripe==2.0.*
|
||||
|
||||
@@ -117,7 +117,7 @@ setup(
|
||||
'django-markup',
|
||||
'markdown<=2.2',
|
||||
'bleach==2.*',
|
||||
'raven',
|
||||
'sentry-sdk==0.7.*',
|
||||
'babel',
|
||||
'paypalrestsdk==1.13.*',
|
||||
'pycparser==2.13',
|
||||
@@ -127,7 +127,7 @@ setup(
|
||||
'chardet<3.1.0,>=3.0.2',
|
||||
'mt-940==3.2',
|
||||
'django-i18nfield>=1.4.0',
|
||||
'django-jsonfallback',
|
||||
'django-jsonfallback>=2.1.2',
|
||||
'psycopg2-binary',
|
||||
'vobject==0.9.*',
|
||||
'pycountry',
|
||||
|
||||
@@ -3,6 +3,7 @@ from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django_countries.fields import Country
|
||||
from pytz import UTC
|
||||
|
||||
@@ -150,7 +151,6 @@ def test_event_list(token_client, organizer, event):
|
||||
def test_event_get(token_client, organizer, event):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
|
||||
assert resp.status_code == 200
|
||||
print(resp.data)
|
||||
assert TEST_EVENT_RES == resp.data
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ def test_event_create(token_client, organizer, event, meta_prop):
|
||||
assert organizer.events.get(slug="2030").meta_values.filter(
|
||||
property__name=meta_prop.name, value="Conference"
|
||||
).exists()
|
||||
assert organizer.events.get(slug="2030").plugins == settings.PRETIX_PLUGINS_DEFAULT
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/'.format(organizer.slug),
|
||||
|
||||
@@ -678,6 +678,17 @@ class OrderChangeManagerTests(TestCase):
|
||||
with self.assertRaises(OrderError):
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
|
||||
def test_change_item_keep_price(self):
|
||||
p = self.op1.price
|
||||
tv = self.op1.tax_value
|
||||
self.ocm.change_item(self.op1, self.shirt, None, keep_price=True)
|
||||
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 == tv
|
||||
|
||||
def test_change_item_success(self):
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit()
|
||||
@@ -805,6 +816,14 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.item == self.ticket
|
||||
|
||||
def test_quota_ignore(self):
|
||||
q = self.event.quotas.create(name='Test', size=0)
|
||||
q.items.add(self.shirt)
|
||||
self.ocm.change_item(self.op1, self.shirt, None)
|
||||
self.ocm.commit(check_quotas=False)
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.item == self.shirt
|
||||
|
||||
def test_quota_full_but_in_same(self):
|
||||
q = self.event.quotas.create(name='Test', size=0)
|
||||
q.items.add(self.shirt)
|
||||
|
||||
@@ -1517,6 +1517,7 @@ def test_refund_paid_order_automatically_failed(client, env, monkeypatch):
|
||||
p.info_data = {
|
||||
'id': 'foo'
|
||||
}
|
||||
p.save()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
@@ -1556,6 +1557,7 @@ def test_refund_paid_order_automatically(client, env, monkeypatch):
|
||||
p.info_data = {
|
||||
'id': 'foo'
|
||||
}
|
||||
p.save()
|
||||
p.confirm()
|
||||
client.login(email='dummy@dummy.dummy', password='dummy')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user