Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel ba7d4ec13c API: Fix crash in check-in API (PRETIXEU-CT1) 2026-01-13 13:16:13 +01:00
257 changed files with 106535 additions and 147027 deletions
+14
View File
@@ -208,6 +208,20 @@ Additionally, when creating a device through the user interface or API, a user c
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
policies for official pretix apps like pretixSCAN and pretixPOS.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Event selection
---------------
-3
View File
@@ -60,9 +60,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.added``
* ``pretix.event.changed``
* ``pretix.event.deleted``
* ``pretix.giftcards.created``
* ``pretix.giftcards.modified``
* ``pretix.giftcards.transaction.*``
* ``pretix.voucher.added``
* ``pretix.voucher.changed``
* ``pretix.voucher.deleted``
+2 -2
View File
@@ -1,6 +1,6 @@
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0
sphinx-rtd-theme~=3.1.0rc2
sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
+2 -2
View File
@@ -1,7 +1,7 @@
-e ../
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0
sphinx-rtd-theme~=3.1.0rc2
sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
+6 -6
View File
@@ -33,7 +33,7 @@ dependencies = [
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=44.0.0",
"css-inline==0.20.*",
"css-inline==0.19.*",
"defusedcsv>=1.1.0",
"dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26",
@@ -41,7 +41,7 @@ dependencies = [
"django-compressor==4.6.0",
"django-countries==8.2.*",
"django-filter==25.1",
"django-formset-js-improved==0.5.0.5",
"django-formset-js-improved==0.5.0.4",
"django-formtools==2.5.1",
"django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*",
@@ -65,7 +65,7 @@ dependencies = [
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.10", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
@@ -73,14 +73,14 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.11.*",
"PyJWT==2.10.*",
"phonenumberslite==9.0.*",
"Pillow==12.1.*",
"pretix-plugin-build",
"protobuf==6.33.*",
"psycopg2-binary",
"pycountry",
"pycparser==3.0",
"pycparser==2.23",
"pycryptodome==3.23.*",
"pypdf==6.5.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
@@ -92,7 +92,7 @@ dependencies = [
"redis==7.1.*",
"reportlab==4.4.*",
"requests==2.32.*",
"sentry-sdk==2.52.*",
"sentry-sdk==2.49.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.2.0.dev0"
__version__ = "2025.11.0.dev0"
-1
View File
@@ -806,7 +806,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_reissue_after_modify',
'invoice_include_free',
'invoice_generate',
'invoice_generate_only_business',
'invoice_period',
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
+2 -13
View File
@@ -191,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
)
break # do not call else branch of for loop
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
elif t.exclusive:
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
@@ -704,16 +704,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
# for performance reasons
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
**self.context,
'expand': [v for v in self.context['expand'] if v != 'addons'],
'pdf_data': False,
})
self.fields['addons'] = subl
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
@@ -1611,7 +1601,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
cp.addon_to, cp.is_bundled, pos._voucher_discount)
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions
]
)
@@ -1743,7 +1733,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding(
rounding_mode,
ia,
self.context["event"].currency,
[*pos_map.values(), *fees]
)
+19 -17
View File
@@ -49,7 +49,7 @@ from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.mail import mail
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -363,21 +363,24 @@ class TeamInviteSerializer(serializers.ModelSerializer):
)
def _send_invite(self, instance):
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.context['organizer'].name,
'team': instance.team.name,
'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=get_language_without_region() # TODO: expose?
)
try:
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.context['organizer'].name,
'team': instance.team.name,
'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=get_language_without_region() # TODO: expose?
)
except SendMailException:
pass # Already logged
def create(self, validated_data):
if 'email' in validated_data:
@@ -440,7 +443,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',
+24 -37
View File
@@ -381,21 +381,15 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.filter(reduce(operator.or_, lists_qs))
prefetch_related = [
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
]
select_related = [
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
]
if pdf_data:
qs = qs.prefetch_related(
# Don't add to list, we don't want to propagate to addons
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'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',
@@ -410,39 +404,32 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
)
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
)
else:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if expand and 'subevent' in expand:
prefetch_related += [
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values'
]
)
if expand and 'item' in expand:
prefetch_related += [
'item', 'item__addons', 'item__bundles', 'item__meta_values',
'item__variations',
]
select_related.append('item__tax_rule')
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
'item__variations').select_related('item__tax_rule')
if expand and 'variation' in expand:
prefetch_related += [
'variation', 'variation__meta_values',
]
if expand and 'addons' in expand:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
]
else:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
]
if pdf_data:
select_related.remove("order") # Don't need it twice on this queryset
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
qs = qs.prefetch_related('variation', 'variation__meta_values')
return qs
+1 -1
View File
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
'limit_sales_channels', 'variations__limit_sales_channels',
).all()
def perform_create(self, serializer):
+12 -2
View File
@@ -90,6 +90,7 @@ from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice, transmit_invoice,
)
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
@@ -438,6 +439,8 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs)
return Response(
@@ -631,7 +634,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order = self.get_object()
if not order.email:
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
order.resend_link(user=self.request.user, auth=self.request.auth)
try:
order.resend_link(user=self.request.user, auth=self.request.auth)
except SendMailException:
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(
status=status.HTTP_204_NO_CONTENT
@@ -1610,6 +1616,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
)
except Quota.QuotaExceededException:
pass
except SendMailException:
pass
serializer = OrderPaymentSerializer(r, context=serializer.context)
@@ -1647,6 +1655,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@@ -2021,7 +2031,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
else:
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
c = generate_cancellation(inv)
if invoice_qualified(order):
if inv.order.status != Order.STATUS_CANCELED:
inv = generate_invoice(order)
else:
inv = c
+7 -16
View File
@@ -249,17 +249,12 @@ class GiftCardViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
value = serializer.validated_data.pop('value')
inst = serializer.save(issuer=self.request.organizer)
inst.log_action(
action='pretix.giftcards.created',
user=self.request.user,
auth=self.request.auth,
)
inst.transactions.create(value=value, acceptor=self.request.organizer)
inst.log_action(
action='pretix.giftcards.transaction.manual',
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
@@ -274,7 +269,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
testmode=serializer.instance.testmode)
inst.log_action(
action='pretix.giftcards.modified',
'pretix.giftcards.modified',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
@@ -287,10 +282,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
diff = value - old_value
inst.transactions.create(value=diff, acceptor=self.request.organizer)
inst.log_action(
action='pretix.giftcards.transaction.manual',
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={'value': diff, 'acceptor_id': self.request.organizer.id}
data={'value': diff}
)
return inst
@@ -314,14 +309,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={
'value': value,
'text': text,
'acceptor_id': self.request.organizer.id
}
data={'value': value, 'text': text}
)
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
-41
View File
@@ -174,35 +174,6 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
}
class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
@@ -462,18 +433,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.customer.anonymized',
_('Customer account anonymized'),
),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.created',
_('Gift card added'),
),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.modified',
_('Gift card modified'),
),
ParametrizedGiftcardTransactionWebhookEvent(
'pretix.giftcards.transaction.*',
_('Gift card used in transcation'),
)
)
+7 -9
View File
@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
markdown_compile_email, truelink_callback,
)
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -141,7 +141,6 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
apply_format_map = not isinstance(plain_body, FormattedString)
body_md = self.compile_markdown(plain_body, context)
if context:
linker = bleach.Linker(
@@ -150,13 +149,12 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True
)
if apply_format_map:
body_md = format_map(
body_md,
context=context,
mode=SafeFormatter.MODE_RICH_TO_HTML,
linkifier=linker
)
body_md = format_map(
body_md,
context=context,
mode=SafeFormatter.MODE_RICH_TO_HTML,
linkifier=linker
)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
+6 -41
View File
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
from django import forms
from django.conf import settings
from django.db.models import (
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, Sum, When,
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When,
)
from django.db.models.functions import Coalesce
from django.dispatch import receiver
@@ -144,18 +144,6 @@ class OrderListExporter(MultiSheetListExporter):
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
del d['event_date_range']
if not self.is_multievent:
d["items"] = forms.ModelMultipleChoiceField(
label=_("Products"),
queryset=self.event.items.all(),
widget=forms.CheckboxSelectMultiple(
attrs={"class": "scrolling-multiple-choice"}
),
help_text=_("If none are selected, all products are included. Orders are included if they contain "
"at least one position of this product. The order totals etc. still include all products "
"contained in the order."),
required=False,
)
return d
def _get_all_payment_methods(self, qs):
@@ -261,14 +249,6 @@ class OrderListExporter(MultiSheetListExporter):
pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address', 'customer')
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('pk'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']:
@@ -384,7 +364,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state_for_address,
order.invoice_address.state,
order.invoice_address.custom_field,
order.invoice_address.vat_id,
]
@@ -460,14 +440,6 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('order'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='order__')
return qs
@@ -543,7 +515,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state_for_address,
order.invoice_address.state,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
@@ -563,11 +535,6 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
item__in=form_data["items"]
)
qs = self._date_filter(qs, form_data, rel='order__')
return qs
@@ -650,7 +617,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Country'),
pgettext('address', 'State'),
_('Voucher'),
_('Voucher budget usage'),
_('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'),
@@ -766,9 +732,8 @@ class OrderListExporter(MultiSheetListExporter):
op.zipcode or '',
op.city or '',
op.country if op.country else '',
op.state_for_address or '',
op.state or '',
op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '',
op.pseudonymization_id,
op.secret,
]
@@ -832,7 +797,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.state_for_address,
order.invoice_address.state,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
+7 -7
View File
@@ -890,18 +890,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text:
if q.valid_date_min and q.valid_date_max:
help_text = format_lazy(
_('Please enter a date between {min} and {max}.'),
'Please enter a date between {min} and {max}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_min:
help_text = format_lazy(
_('Please enter a date no earlier than {min}.'),
'Please enter a date no earlier than {min}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
)
elif q.valid_date_max:
help_text = format_lazy(
_('Please enter a date no later than {max}.'),
'Please enter a date no later than {max}.',
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
)
if initial and initial.answer:
@@ -939,18 +939,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text:
if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'),
'Please enter a date and time between {min} and {max}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_min:
help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'),
'Please enter a date and time no earlier than {min}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
)
elif q.valid_datetime_max:
help_text = format_lazy(
_('Please enter a date and time no later than {max}.'),
'Please enter a date and time no later than {max}.',
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
)
@@ -1417,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
elif transmission_type.exclusive:
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
+12 -50
View File
@@ -34,13 +34,14 @@
from contextlib import contextmanager
from asgiref.local import Local
from babel import localedata
from django.conf import settings
from django.utils import translation
from django.utils.formats import date_format, number_format
from django.utils.translation import gettext
from pretix.base.templatetags.money import money_filter
from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
)
@@ -50,9 +51,6 @@ from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa
_active_region = Local()
class LazyDate:
def __init__(self, value):
self.value = value
@@ -88,8 +86,6 @@ class LazyCurrencyNumber:
return self.__str__()
def __str__(self):
from pretix.base.templatetags.money import money_filter
return money_filter(self.value, self.currency)
@@ -109,41 +105,14 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale():
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
# Also, this returns best-effort region information for number formatting etc
current_language = translation.get_language()
current_region = getattr(_active_region, "value", None)
# Babel only accepts locales that exist on the system. We try combinations in the following order:
# language-languageversion-region
# language-region
# language-languageversion
# language
# fallback to system default
# fallback to english
try_locales = []
if current_language:
if "-" in current_language:
lng_parts = current_language.split("-")
if current_region:
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
try_locales.append(f"{lng_parts[0]}")
else:
if current_region:
try_locales.append(f"{current_language}_{current_region.upper()}")
try_locales.append(f"{current_language}")
try_locales.append(settings.LANGUAGE_CODE)
for locale in try_locales:
if localedata.exists(locale):
return localedata.normalize_locale(locale)
return "en"
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if translation.get_language():
if localedata.exists(translation.get_language()):
babel_locale = translation.get_language()
elif localedata.exists(translation.get_language()[:2]):
babel_locale = translation.get_language()[:2]
return babel_locale
def get_language_without_region(lng=None):
@@ -163,10 +132,6 @@ def get_language_without_region(lng=None):
return lng
def set_region(region):
_active_region.value = region
@contextmanager
def language(lng, region=None):
"""
@@ -178,18 +143,15 @@ def language(lng, region=None):
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
attribute will be ignored.
"""
lng_before = translation.get_language()
region_before = getattr(_active_region, "value", None)
_lng = translation.get_language()
lng = lng or settings.LANGUAGE_CODE
if '-' not in lng and region:
lng += '-' + region.lower()
translation.activate(lng)
_active_region.value = region
try:
yield
finally:
translation.activate(lng_before)
_active_region.value = region_before
translation.activate(_lng)
class LazyLocaleException(Exception):
+39 -35
View File
@@ -33,7 +33,7 @@ from pretix.base.invoicing.transmission import (
transmission_types,
)
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers.format import format_map
@@ -133,37 +133,41 @@ class EmailTransmissionProvider(TransmissionProvider):
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
# Do not set to completed because that is done by the email sending task
subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
[recipient],
subject,
template,
context=context,
event=invoice.order.event,
locale=invoice.order.locale,
order=invoice.order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
)
try:
# Do not set to completed because that is done by the email sending task
subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
[recipient],
subject,
template,
context=context,
event=invoice.order.event,
locale=invoice.order.locale,
order=invoice.order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
)
+1 -3
View File
@@ -36,11 +36,9 @@ class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
return str(country) == "IT"
def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business)
+1 -7
View File
@@ -64,7 +64,7 @@ class PeppolIdValidator:
"0020": "[0-9]{9}",
"0201": "[0-9a-zA-Z]{6}",
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
"0208": "[01][0-9]{9}",
"0208": "0[0-9]{9}",
"0209": ".*",
"0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}",
@@ -179,12 +179,6 @@ class PeppolTransmissionType(TransmissionType):
def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
# Peppol is required to be used for intra-Belgian B2B invoices
return True
return False
@property
def invoice_address_form_fields(self) -> dict:
return {
+9 -26
View File
@@ -21,7 +21,6 @@
#
from typing import Optional
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from pretix.base.models import Invoice, InvoiceAddress
@@ -59,6 +58,15 @@ class TransmissionType:
"""
return 100
@property
def exclusive(self) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction.
"""
return False
@property
def enforce_transmission(self) -> bool:
"""
@@ -74,15 +82,6 @@ class TransmissionType:
for provider, _ in providers
)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
the type is not available.
"""
return False
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set()
@@ -107,22 +106,6 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
visible_field_keys = self.invoice_address_form_fields_visible(country, is_business)
for k, f in self.invoice_address_form_fields.items():
if k not in visible_field_keys:
continue
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data
def pdf_watermark(self) -> Optional[str]:
"""
Return a watermark that should be rendered across the PDF file.
+7 -21
View File
@@ -294,28 +294,14 @@ def metric_values():
channel = app.broker_connection().channel()
if hasattr(channel, 'client') and channel.client is not None:
client = channel.client
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
for q in settings.CELERY_TASK_QUEUES:
queue_lengths = []
queue_delays = []
for prio in priority_steps:
if prio:
qname = f"{q.name}{sep}{prio}"
else:
qname = q.name
queue_length = client.llen(qname)
queue_lengths.append(queue_length)
oldest_queue_item = client.lindex(qname, -1)
if oldest_queue_item:
ldata = json.loads(oldest_queue_item)
oldest_item_age = time.time() - ldata.get('created', 0)
queue_delays.append(oldest_item_age)
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = sum(queue_lengths)
if queue_delays:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = max(queue_delays)
llen = client.llen(q.name)
lfirst = client.lindex(q.name, -1)
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
if lfirst:
ldata = json.loads(lfirst)
dt = time.time() - ldata.get('created', 0)
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
else:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
+1 -5
View File
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
parse_accept_lang_header,
)
from pretix.base.i18n import get_language_without_region, set_region
from pretix.base.i18n import get_language_without_region
from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
@@ -92,14 +92,10 @@ class LocaleMiddleware(MiddlewareMixin):
)
if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region
if settings_holder.settings.region:
set_region(settings_holder.settings.region)
else:
gs = global_settings_object(request)
if '-' not in language and gs.settings.region:
language += '-' + gs.settings.region
if gs.settings.region:
set_region(gs.settings.region)
translation.activate(language)
request.LANGUAGE_CODE = get_language_without_region()
@@ -1,120 +0,0 @@
# Generated by Django 4.2.26 on 2026-01-22 13:44
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.mail
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0296_invoice_invoice_from_state"),
]
operations = [
migrations.CreateModel(
name="OutgoingMail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
("status", models.CharField(default="queued", max_length=200)),
("created", models.DateTimeField(auto_now_add=True)),
("sent", models.DateTimeField(blank=True, null=True)),
("inflight_since", models.DateTimeField(blank=True, null=True)),
("retry_after", models.DateTimeField(blank=True, null=True)),
("error", models.TextField(null=True)),
("error_detail", models.TextField(null=True)),
("sensitive", models.BooleanField(default=False)),
("subject", models.TextField()),
("body_plain", models.TextField()),
("body_html", models.TextField(null=True)),
("sender", models.CharField(max_length=500)),
("headers", models.JSONField(default=dict)),
("to", models.JSONField(default=list)),
("cc", models.JSONField(default=list)),
("bcc", models.JSONField(default=list)),
("recipient_count", models.IntegerField()),
("should_attach_tickets", models.BooleanField(default=False)),
("should_attach_ical", models.BooleanField(default=False)),
("should_attach_other_files", models.JSONField(default=list)),
("actual_attachments", models.JSONField(default=list)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.event",
),
),
(
"order",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.order",
),
),
(
"orderposition",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.orderposition",
),
),
(
"organizer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to="pretixbase.organizer",
),
),
(
"should_attach_cached_files",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.cachedfile"
),
),
(
"should_attach_invoices",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.invoice"
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("-created",),
},
),
]
-1
View File
@@ -41,7 +41,6 @@ from .items import (
itempicture_upload_to,
)
from .log import LogEntry
from .mail import OutgoingMail
from .media import ReusableMedium
from .memberships import Membership, MembershipType
from .notifications import NotificationSetting
+19 -16
View File
@@ -334,24 +334,27 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.email
def send_security_notice(self, messages, email=None):
from pretix.base.services.mail import mail
from pretix.base.services.mail import SendMailException, mail
with language(self.locale):
msg = '- ' + '\n- '.join(str(m) for m in messages)
try:
with language(self.locale):
msg = '- ' + '\n- '.join(str(m) for m in messages)
mail(
email or self.email,
_('Account information changed'),
'pretixcontrol/email/security_notice.txt',
{
'user': self,
'messages': msg,
'url': build_absolute_uri('control:user.settings')
},
event=None,
user=self,
locale=self.locale
)
mail(
email or self.email,
_('Account information changed'),
'pretixcontrol/email/security_notice.txt',
{
'user': self,
'messages': msg,
'url': build_absolute_uri('control:user.settings')
},
event=None,
user=self,
locale=self.locale
)
except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None):
"""
-2
View File
@@ -130,8 +130,6 @@ class LoggingMixin:
organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'):
organizer_id = self.organizer_id
elif hasattr(self, 'issuer_id'):
organizer_id = self.issuer_id
if user and not user.is_authenticated:
user = None
+1 -25
View File
@@ -40,7 +40,6 @@ from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.i18n import language
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.giftcards import GiftCardTransaction
@@ -165,28 +164,6 @@ class Customer(LoggedModel):
self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete()
def send_security_notice(self, message, email=None):
from pretix.base.services.mail import SendMailException, mail
from pretix.multidomain.urlreverse import build_absolute_uri
try:
with language(self.locale):
mail(
email or self.email,
self.organizer.settings.mail_subject_customer_security_notice,
self.organizer.settings.mail_text_customer_security_notice,
{
**self.get_email_context(),
'message': str(message),
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
},
customer=self,
organizer=self.organizer,
locale=self.locale
)
except SendMailException:
pass # Already logged
@scopes_disabled()
def assign_identifier(self):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
@@ -316,7 +293,6 @@ class Customer(LoggedModel):
locale=self.locale,
customer=self,
organizer=self.organizer,
sensitive=True,
)
def usable_gift_cards(self, used_cards=[]):
@@ -373,7 +349,7 @@ class AttendeeProfile(models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return _(sd.name)
return sd.name
return self.state
@property
+5 -41
View File
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
'voucher_discount'])
@@ -279,42 +279,6 @@ class Discount(LoggedModel):
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _addon_idx(self, positions, idx):
"""
If we have the following cart:
- Main product
- 10x Addon product 5
- Main product
- 10x Addon product 5
And we have a discount rule that grants "every 10th product is free", people tend to expect
- Main product
- 9x Addon product 5
- 1x Addon product free
- Main product
- 9x Addon product 5
- 1x Addon product free
And get confused if they get
- Main product
- 8x Addon product 5
- 2x Addon product free
- Main product
- 10x Addon product 5
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
the weird case less likely.
"""
if not positions[idx].addon_to:
return 0
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count:
return
@@ -324,8 +288,8 @@ class Discount(LoggedModel):
if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
@@ -470,7 +434,7 @@ class Discount(LoggedModel):
for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
v.sort(key=lambda idx: positions[idx].line_price_gross)
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
@@ -494,7 +458,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
+4 -5
View File
@@ -594,11 +594,10 @@ class Item(LoggedModel):
on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"),
help_text=_("If you select a product here, this product will only be shown when that product is "
"no longer available. This will happen either because the other product has sold out or because "
"the time is outside of the sales window for the other product. If combined with the option "
"to hide sold-out products, this allows you to swap out products for more expensive ones once "
"the cheaper option is sold out. There might be a short period in which both products are visible "
"while all tickets of the referenced product are reserved, but not yet sold.")
"sold out. If combined with the option to hide sold-out products, this allows you to "
"swap out products for more expensive ones once the cheaper option is sold out. There might "
"be a short period in which both products are visible while all tickets of the referenced "
"product are reserved, but not yet sold.")
)
hidden_if_item_available_mode = models.CharField(
choices=UNAVAIL_MODES,
+1 -2
View File
@@ -141,9 +141,8 @@ class LogEntry(models.Model):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type:
sender = self.event if self.event else self.organizer
link_info = log_entry_type.get_object_link_info(self)
if is_app_active(sender, meta['plugin']):
if is_app_active(self.event, meta['plugin']):
return make_link(link_info, log_entry_type.object_link_wrapper)
else:
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
-222
View File
@@ -1,222 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import uuid
from django.core.mail import get_connection
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
cascade_objs = [
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
]
if cascade_objs:
models.CASCADE(collector, field, cascade_objs, using)
# In all other cases, set to NULL to keep the email on record
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
class OutgoingMail(models.Model):
STATUS_QUEUED = "queued"
STATUS_WITHHELD = "withheld"
STATUS_INFLIGHT = "inflight"
STATUS_AWAITING_RETRY = "awaiting_retry"
STATUS_FAILED = "failed"
STATUS_SENT = "sent"
STATUS_BOUNCED = "bounced"
STATUS_ABORTED = "aborted"
STATUS_CHOICES = (
(STATUS_QUEUED, _("queued")),
(STATUS_INFLIGHT, _("being sent")),
(STATUS_AWAITING_RETRY, _("awaiting retry")),
(STATUS_WITHHELD, _("withheld")), # for plugin use
(STATUS_FAILED, _("failed")),
(STATUS_ABORTED, _("aborted")),
(STATUS_SENT, _("sent")),
(STATUS_BOUNCED, _("bounced")), # for plugin use
)
STATUS_LIST_ABORTABLE = {
STATUS_QUEUED,
STATUS_WITHHELD,
STATUS_AWAITING_RETRY,
}
STATUS_LIST_RETRYABLE = {
STATUS_FAILED,
STATUS_WITHHELD,
}
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
# their own Message-ID headers.
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
created = models.DateTimeField(auto_now_add=True)
# sent will be the time the email was sent or the email failed
sent = models.DateTimeField(null=True, blank=True)
inflight_since = models.DateTimeField(null=True, blank=True)
retry_after = models.DateTimeField(null=True, blank=True)
error = models.TextField(null=True, blank=True)
error_detail = models.TextField(null=True, blank=True)
# There is a conflict here between the different purposes of the model. As a system administrator,
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
# would be an "orphan" forever and there's no way to remove the personal information.
# We try to find a middle-ground with the following behaviour:
# - The email is always deleted if the entire organizer or user is deleted
# - The email is always deleted if it has not yet been sent
# - The email is kept in all other cases
# This is only an acceptable trade-off since emails are stored for a short period only, and because
# orders and customers are never deleted during normal operation. If we ever make this a long-term
# storage / email archive, we'd need to find another way to make sure personal information is removed
# if personal information of orders etc is removed.
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
event = models.ForeignKey(
'pretixbase.Event',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
order = models.ForeignKey(
'pretixbase.Order',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
orderposition = models.ForeignKey(
'pretixbase.OrderPosition',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
customer = models.ForeignKey(
'pretixbase.Customer',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
user = models.ForeignKey(
'pretixbase.User',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
sensitive = models.BooleanField(default=False)
subject = models.TextField()
body_plain = models.TextField()
body_html = models.TextField(null=True)
sender = models.CharField(max_length=500)
headers = models.JSONField(default=dict)
to = models.JSONField(default=list)
cc = models.JSONField(default=list)
bcc = models.JSONField(default=list)
recipient_count = models.IntegerField()
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
# newer version of the files might be used. We accept that risk to save on storage and also because the new
# version might actually be more useful.
should_attach_invoices = models.ManyToManyField(
'pretixbase.Invoice',
related_name='outgoing_mails'
)
should_attach_tickets = models.BooleanField(default=False)
should_attach_ical = models.BooleanField(default=False)
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
should_attach_cached_files = models.ManyToManyField(
'pretixbase.CachedFile',
related_name='outgoing_mails',
)
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
should_attach_other_files = models.JSONField(default=list)
# [{name, type size}] of the attachments we actually setn
actual_attachments = models.JSONField(default=list)
class Meta:
ordering = ('-created',)
def get_mail_backend(self):
if self.event:
return self.event.get_mail_backend()
elif self.organizer:
return self.organizer.get_mail_backend()
else:
return get_connection(fail_silently=False)
def scope_manager(self):
if self.organizer:
return scope(organizer=self.organizer) # noqa
else:
return scopes_disabled() # noqa
@property
def is_failed(self):
return self.status in (
OutgoingMail.STATUS_FAILED,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_BOUNCED,
)
def save(self, *args, **kwargs):
if self.orderposition_id and not self.order_id:
self.order = self.orderposition.order
if self.order_id and not self.event_id:
self.event = self.order.event
if self.event_id and not self.organizer_id:
self.organizer = self.event.organizer
if self.customer_id and not self.organizer_id:
self.organizer = self.customer.organizer
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
super().save(*args, **kwargs)
def log_parameters(self):
if self.order:
error_log_action_type = 'pretix.event.order.email.error'
log_target = self.order
elif self.customer:
error_log_action_type = 'pretix.customer.email.error'
log_target = self.customer
elif self.user:
error_log_action_type = 'pretix.user.email.error'
log_target = self.user
else:
error_log_action_type = 'pretix.email.error'
log_target = None
return log_target, error_log_action_type
+100 -72
View File
@@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import FormattedString, format_map
from ...helpers.format import format_map
from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var
from ._transactions import (
@@ -1167,7 +1167,9 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import (
SendMailException, mail, render_mail,
)
if not self.email and not (position and position.attendee_email):
return
@@ -1177,32 +1179,35 @@ class Order(LockModel, LoggedModel):
if position and position.attendee_email:
recipient = position.attendee_email
email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
)
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
def resend_link(self, user=None, auth=None):
with language(self.locale, self.event.settings.region):
@@ -1670,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return _(sd.name)
return sd.name
return self.state
@property
@@ -2019,30 +2024,40 @@ class OrderPayment(models.Model):
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid_attendee
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
position.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
try:
position.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order paid email could not be sent')
def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid
email_subject = self.order.event.settings.mail_subject_order_paid
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
try:
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property
def refunded_amount(self):
@@ -2900,40 +2915,45 @@ class OrderPosition(AbstractPosition):
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import mail, render_mail
from pretix.base.services.mail import (
SendMailException, mail, render_mail,
)
if not self.attendee_email:
return
with language(self.order.locale, self.order.event.settings.region):
recipient = self.attendee_email
email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
position=self,
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
self.order.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
)
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
position=self,
invoices=invoices,
attach_tickets=attach_tickets,
attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
else:
self.order.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
)
def resend_link(self, user=None, auth=None):
@@ -3460,7 +3480,7 @@ class InvoiceAddress(models.Model):
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return _(sd.name)
return sd.name
return self.state
@property
@@ -3509,10 +3529,18 @@ class InvoiceAddress(models.Model):
def describe_transmission(self):
from pretix.base.invoicing.transmission import transmission_types
data = []
t, __ = transmission_types.get(identifier=self.transmission_type)
data.append((_("Transmission type"), t.public_name))
if self.transmission_info:
data += t.describe_info(self.transmission_info, self.country, self.is_business)
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
for k, f in t.invoice_address_form_fields.items():
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data
+46 -49
View File
@@ -34,8 +34,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import User, Voucher
from pretix.base.services.mail import mail, render_mail
from pretix.helpers import OF_SELF
from pretix.base.services.mail import SendMailException, mail, render_mail
from ...helpers.format import format_map
from ...helpers.names import build_name
@@ -159,7 +158,6 @@ class WaitingListEntry(LoggedModel):
if availability[1] is None or availability[1] < 1:
raise WaitingListException(_('This product is currently not available.'))
event = self.event
ev = self.subevent or self.event
if ev.seat_category_mappings.filter(product=self.item).exists():
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
@@ -187,49 +185,44 @@ class WaitingListEntry(LoggedModel):
if not free_seats:
raise WaitingListException(_('No seat with this product is currently available.'))
if self.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
if '@' not in self.email:
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic():
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
locked_wle.event = event
if locked_wle.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
e = locked_wle.email
if locked_wle.name:
e += ' / ' + locked_wle.name
e = self.email
if self.name:
e += ' / ' + self.name
v = Voucher.objects.create(
event=locked_wle.event,
event=self.event,
max_usages=1,
valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours),
item=locked_wle.item,
variation=locked_wle.variation,
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
item=self.item,
variation=self.variation,
tag='waiting-list',
comment=_('Automatically created from waiting list entry for {email}').format(
email=e
),
block_quota=True,
subevent=locked_wle.subevent,
subevent=self.subevent,
)
v.log_action('pretix.voucher.added', {
'item': locked_wle.item.pk,
'variation': locked_wle.variation.pk if locked_wle.variation else None,
'item': self.item.pk,
'variation': self.variation.pk if self.variation else None,
'tag': 'waiting-list',
'block_quota': True,
'valid_until': v.valid_until.isoformat(),
'max_usages': 1,
'subevent': locked_wle.subevent.pk if locked_wle.subevent else None,
'subevent': self.subevent.pk if self.subevent else None,
'source': 'waitinglist',
}, user=user, auth=auth)
v.log_action('pretix.voucher.added.waitinglist', {
'email': locked_wle.email,
'waitinglistentry': locked_wle.pk,
'email': self.email,
'waitinglistentry': self.pk,
}, user=user, auth=auth)
locked_wle.voucher = v
locked_wle.save()
self.refresh_from_db()
self.event = event
self.voucher = v
self.save()
with language(self.locale, self.event.settings.region):
self.send_mail(
@@ -272,30 +265,34 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region):
recipient = self.email
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event,
self.locale,
headers=headers,
sender=sender,
auto_email=auto_email,
attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event,
self.locale,
headers=headers,
sender=sender,
auto_email=auto_email,
attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
@staticmethod
def clean_itemvar(event, item, variation):
+2 -17
View File
@@ -1231,8 +1231,8 @@ class ManualPayment(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
def order_change_allowed(self, order: Order, request=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order, request)
def order_change_allowed(self, order: Order):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
@property
def public_name(self):
@@ -1646,13 +1646,6 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk,
}
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
gc.log_action(
action='pretix.giftcards.transaction.payment',
data={
'value': trans.value,
'acceptor_id': self.event.organizer.id
}
)
except PaymentException as e:
payment.fail(info={'error': str(e)})
raise e
@@ -1677,14 +1670,6 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk,
}
refund.done()
gc.log_action(
action='pretix.giftcards.transaction.refund',
data={
'value': refund.amount,
'acceptor_id': self.event.organizer.id,
'text': refund.comment,
}
)
@receiver(register_payment_providers, dispatch_uid="payment_free")
+1 -1
View File
@@ -65,7 +65,7 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
+29 -20
View File
@@ -36,7 +36,7 @@ from pretix.base.models import (
SubEvent, TaxRule, User, WaitingListEntry,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import mail
from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
@@ -53,14 +53,17 @@ logger = logging.getLogger(__name__)
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
with language(wle.locale, wle.event.settings.region):
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
mail(
wle.email,
format_map(subject, email_context),
message,
email_context,
wle.event,
locale=wle.locale
)
try:
mail(
wle.email,
format_map(subject, email_context),
message,
email_context,
wle.event,
locale=wle.locale
)
except SendMailException:
logger.exception('Waiting list canceled email could not be sent')
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
@@ -74,11 +77,14 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
real_subject = format_map(subject, email_context)
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
user,
)
try:
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
user,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in positions:
if subevent and p.subevent_id != subevent.id:
@@ -91,12 +97,15 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
refund_amount=refund_amount,
position_or_address=p,
order=order, position=p)
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
position=p,
user=user
)
try:
order.send_mail(
real_subject, message, email_context,
'pretix.event.order.email.event_canceled',
position=p,
user=user
)
except SendMailException:
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
+3 -3
View File
@@ -1528,7 +1528,7 @@ class CartManager:
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
]
)
@@ -1639,7 +1639,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments:
@@ -1679,7 +1679,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
fees.append(pf)
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
+2 -14
View File
@@ -23,12 +23,11 @@ from datetime import timedelta
from django.conf import settings
from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket, OutgoingMail
from pretix.base.models import CachedCombinedTicket, CachedTicket
from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
@@ -50,18 +49,7 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_files(sender, **kwargs):
has_queued_email = Exists(
OutgoingMail.objects.filter(
should_attach_cached_files__pk=OuterRef("pk"),
status__in=(
OutgoingMail.STATUS_QUEUED,
OutgoingMail.STATUS_INFLIGHT,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_FAILED,
),
)
)
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()).exclude(has_queued_email):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
cf.delete()
+1 -1
View File
@@ -121,7 +121,7 @@ class CrossSellingService:
self.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled,
bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
+2 -13
View File
@@ -521,20 +521,9 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order):
if order.total == Decimal('0.00'):
if order.total == Decimal('0.00') or order.require_approval or \
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
return False
if order.require_approval:
return False
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
return False
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
return False
if order.event.settings.invoice_generate_only_business:
try:
ia = order.invoice_address
return ia.is_business
except InvoiceAddress.DoesNotExist:
return False
return True
File diff suppressed because it is too large Load Diff
+9 -23
View File
@@ -19,8 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import uuid
import css_inline
from django.conf import settings
from django.template.loader import get_template
@@ -28,9 +26,7 @@ from django.utils.timezone import override
from django_scopes import scope, scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
LogEntry, NotificationSetting, OutgoingMail, User,
)
from pretix.base.models import LogEntry, NotificationSetting, User
from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
@@ -157,26 +153,16 @@ def send_notification_mail(notification: Notification, user: User):
tpl_plain = get_template('pretixbase/email/notification.txt')
body_plain = tpl_plain.render(ctx)
guid = uuid.uuid4()
m = OutgoingMail.objects.create(
guid=guid,
user=user,
to=[user.email],
subject='[{}] {}: {}'.format(
mail_send_task.apply_async(kwargs={
'to': [user.email],
'subject': '[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(),
notification.title
),
body_plain=body_plain,
body_html=body_html,
sender=settings.MAIL_FROM_NOTIFICATIONS,
headers={
'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN',
'Auto-Submitted': 'auto-generated',
'X-Mailer': 'pretix',
'X-PX-Correlation': str(guid),
},
)
mail_send_task.apply_async(kwargs={
'outgoing_mail': m.pk,
'body': body_plain,
'html': body_html,
'sender': settings.MAIL_FROM_NOTIFICATIONS,
'headers': {},
'user': user.pk
})
+147 -187
View File
@@ -90,6 +90,7 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
)
from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order,
)
@@ -247,15 +248,6 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
auth=auth,
data={
'value': position.price,
'acceptor_id': order.event.organizer.id
}
)
break
for m in position.granted_memberships.all():
@@ -446,27 +438,33 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
email_context = get_email_context(event=order.event, order=order)
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
attach_ical=order.event.settings.mail_attach_ical and (
not order.event.settings.mail_attach_ical_paid_only or
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and transmit_invoice_mail else []
)
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
attach_ical=order.event.settings.mail_attach_ical and (
not order.event.settings.mail_attach_ical_paid_only or
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and transmit_invoice_mail else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
email_attendee_context = get_email_context(event=order.event, order=order, position=p)
p.send_mail(
email_attendee_subject, email_attendee_template, email_attendee_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
)
try:
p.send_mail(
email_attendee_subject, email_attendee_template, email_attendee_context,
'pretix.event.order.email.order_approved', user,
attach_tickets=True,
)
except SendMailException:
logger.exception('Order approved email could not be sent to attendee')
return order.pk
@@ -503,10 +501,13 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_denied', user
)
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_denied', user
)
except SendMailException:
logger.exception('Order denied email could not be sent')
return order.pk
@@ -557,14 +558,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
)
else:
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
data={
'value': -position.price,
'acceptor_id': order.event.organizer.id,
}
)
for m in position.granted_memberships.all():
m.canceled = True
@@ -667,11 +660,14 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_template = order.event.settings.mail_text_order_canceled
email_subject = order.event.settings.mail_subject_order_canceled
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user,
invoices=transmit_invoices_mail,
)
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user,
invoices=transmit_invoices_mail,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
try:
@@ -918,7 +914,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
]
)
@@ -972,7 +968,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00")
@@ -999,7 +995,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
p['fee'] = pf
# Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
@@ -1112,40 +1108,46 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments)
order.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
try:
order.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position)
position.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
try:
position.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
is_free or
order.valid_if_pending
),
attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
@@ -1474,10 +1476,13 @@ def send_expiry_warnings(sender, **kwargs):
email_template = settings.mail_text_order_pending_warning
email_subject = settings.mail_subject_order_pending_warning
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.expire_warning_sent'
)
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.expire_warning_sent'
)
except SendMailException:
logger.exception('Reminder email could not be sent')
@receiver(signal=periodic_task)
@@ -1538,11 +1543,14 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
@@ -1556,11 +1564,14 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
@@ -1568,10 +1579,13 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
email_template = order.event.settings.mail_text_order_changed
email_context = get_email_context(event=order.event, order=order)
email_subject = order.event.settings.mail_subject_order_changed
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
)
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
)
except SendMailException:
logger.exception('Order changed email could not be sent')
class OrderChangeManager:
@@ -1627,7 +1641,6 @@ class OrderChangeManager:
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult:
_position: Optional[OrderPosition]
@@ -1791,7 +1804,6 @@ class OrderChangeManager:
positions = self.order.positions.select_related('item', 'item__tax_rule')
ia = self._invoice_address
tax_rules = self._current_tax_rules()
self._operations.append(self.ForceRecomputeOperation())
for pos in positions:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
@@ -2082,43 +2094,6 @@ class OrderChangeManager:
)
item_counts[item] += 1
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not a.item.all_sales_channels and
not a.item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
# "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
# Check constraints on the add-on combinations
for op in toplevel_op:
item = op.item
@@ -2151,6 +2126,41 @@ class OrderChangeManager:
}
)
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
for item, count in item_counts.items():
if count == 0:
continue
@@ -2451,15 +2461,6 @@ class OrderChangeManager:
))
else:
gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -position.price,
'acceptor_id': self.order.event.organizer.id
}
)
for m in position.granted_memberships.with_usages().all():
m.canceled = True
@@ -2477,15 +2478,6 @@ class OrderChangeManager:
))
else:
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -opa.position.price,
'acceptor_id': self.order.event.organizer.id
}
)
for m in opa.granted_memberships.with_usages().all():
m.canceled = True
@@ -2648,10 +2640,6 @@ class OrderChangeManager:
except BlockedTicketSecret.DoesNotExist:
pass
# todo: revoke list handling
elif isinstance(op, self.ForceRecomputeOperation):
self.order.log_action('pretix.event.order.changed.recomputed', user=self.user, auth=self.auth, data={})
else:
raise TypeError(f"Unknown operation {type(op)}")
for p in secret_dirty:
assign_ticket_secret(
@@ -2706,10 +2694,7 @@ class OrderChangeManager:
fees.append(new_fee)
changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode,
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled])
@@ -2731,10 +2716,7 @@ class OrderChangeManager:
fee.delete()
changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode,
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
@@ -2851,12 +2833,7 @@ class OrderChangeManager:
if fee_changed:
fees = list(self.order.fees.all())
changed = apply_rounding(
self.order.tax_rounding_mode,
self._invoice_address,
self.order.event.currency,
[*positions, *fees]
)
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
for l in changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
@@ -3154,10 +3131,7 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
customer=order.customer,
testmode=order.testmode
)
giftcard.log_action(
action='pretix.giftcards.created',
data={}
)
giftcard.log_action('pretix.giftcards.created', data={})
r = order.refunds.create(
order=order,
payment=None,
@@ -3295,12 +3269,8 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
positions = list(order.positions.all())
fees = list(order.fees.all())
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
rounding_changed = set(apply_rounding(
order.tax_rounding_mode, ia, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
@@ -3325,7 +3295,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = None
rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, ia, order.event.currency, [*positions, *fees]
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
))
for l in rounding_changed:
if isinstance(l, OrderPosition):
@@ -3444,17 +3414,7 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
currency=sender.currency, issued_in=p, testmode=order.testmode,
expires=sender.organizer.default_gift_card_expiry,
)
gc.log_action(
action='pretix.giftcards.created',
)
trans = gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
data={
'value': trans.value,
'acceptor_id': order.event.organizer.id,
}
)
gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
any_giftcards = True
p.secret = gc.secret
p.save(update_fields=['secret'])
+5 -14
View File
@@ -174,9 +174,7 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
@@ -198,9 +196,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
@@ -211,8 +209,7 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"],
invoice_address: Optional[InvoiceAddress], currency: str,
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
@@ -227,17 +224,11 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``.
:param invoice_address: The invoice address, or ``None``
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
:param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
"""
if rounding_mode == "sum_by_net_only_business":
if invoice_address and invoice_address.is_business:
rounding_mode = "sum_by_net"
else:
rounding_mode = "line"
def _key(line):
return (line.tax_rate, line.tax_code or "")
+19 -16
View File
@@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _
from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name
from pretix.base.services.mail import mail
from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.shredder import ShredError
from pretix.celery_app import app
@@ -171,18 +171,21 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user:
with language(user.locale):
mail(
user.email,
_('Data shredding completed'),
'pretixbase/email/shred_completed.txt',
{
'user': user,
'organizer': event.organizer.name,
'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
},
event=None,
user=user,
locale=user.locale,
)
try:
mail(
user.email,
_('Data shredding completed'),
'pretixbase/email/shred_completed.txt',
{
'user': user,
'organizer': event.organizer.name,
'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
},
event=None,
user=user,
locale=user.locale,
)
except SendMailException:
pass # Already logged
+1 -10
View File
@@ -112,8 +112,7 @@ def dictsum(*dicts) -> dict:
def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None,
skip_empty_lines=False,
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related(
'category', # for re-grouping
@@ -206,21 +205,13 @@ def order_overview(
for l in states.keys():
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
var._skip = all(v[0] == 0 for v in var.num.values())
for l in states.keys():
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
if skip_empty_lines:
item.all_variations = [v for v in item.all_variations if not v._skip]
item._skip = not item.all_variations
else:
for l in states.keys():
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
item._skip = all(v[0] == 0 for v in item.num.values())
if skip_empty_lines:
items = [i for i in items if not i._skip]
nonecat = ItemCategory(name=_('Uncategorized'))
# Regroup those by category
+2 -49
View File
@@ -76,12 +76,11 @@ from pretix.base.validators import multimail_validate
from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
)
from pretix.helpers.countries import CachedCountries, pycountry_add
from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it
)
@@ -181,19 +180,6 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_require_login_for_order_access': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require login to access order confirmation pages"),
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
"account is activated."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_link_by_email': {
'default': 'False',
'type': bool,
@@ -1226,15 +1212,6 @@ DEFAULTS = {
'default': json.dumps(['web']),
'type': list
},
'invoice_generate_only_business': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Only issue invoices to business customers"),
)
},
'invoice_address_from': {
'default': '',
'type': str,
@@ -2948,28 +2925,6 @@ If you did not request a new password, please ignore this email.
Best regards,
Your {organizer} team""")) # noqa: W291
},
'mail_subject_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
},
'mail_text_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
the following change has been made to your account at {organizer}:
{message}
You can review and change your account settings here:
{url}
If this change was not performed by you, please contact us immediately.
Best regards,
Your {organizer} team""")) # noqa: W291
},
'smtp_use_custom': {
@@ -3959,7 +3914,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'),
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
'Decentralized regional entity'], 'short'),
'Free municipal consortium', 'Decentralized regional entity'], 'short'),
}
COUNTRY_STATE_LABEL = {
# Countries in which the "State" field should not be called "State"
@@ -3967,8 +3922,6 @@ COUNTRY_STATE_LABEL = {
'JP': pgettext_lazy('address', 'Prefecture'),
'IT': pgettext_lazy('address', 'Province'),
}
# Workaround for https://github.com/pretix/pretix/issues/5796
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
settings_hierarkey = Hierarkey(attribute_name='settings')
+1 -5
View File
@@ -51,7 +51,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
OrderPosition, OrderRefund, QuestionAnswer,
)
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders
@@ -329,10 +329,6 @@ class EmailAddressShredder(BaseDataShredder):
sleep_time=2,
)
slow_delete(
OutgoingMail.objects.filter(event=self.event)
)
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
changed = bool(o.email) or bool(o.customer)
o.email = None
+2 -10
View File
@@ -944,40 +944,32 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
email_filter = EventPluginSignal()
"""
Arguments: ``message``, ``order``, ``user``, ``outgoing_mail``
Arguments: ``message``, ``order``, ``user``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
"""
global_email_filter = GlobalSignal()
"""
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail``
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you.
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
"""
+3
View File
@@ -8,6 +8,9 @@
<h1>{% trans "Not found" %}</h1>
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
<p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
<p>
+8 -6
View File
@@ -26,8 +26,7 @@ from babel.numbers import format_currency
from django import template
from django.conf import settings
from django.template.defaultfilters import floatformat
from pretix.base.i18n import get_babel_locale
from django.utils import translation
register = template.Library()
@@ -60,10 +59,13 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
if hide_currency:
return floatformat(value, f"{places}g")
try:
locale = Locale(get_babel_locale())
except UnknownLocaleError:
locale = "en"
locale_parts = translation.get_language().split("-", 1)
locale = locale_parts[0]
if len(locale_parts) > 1 and len(locale_parts[1]) == 2:
try:
locale = Locale(locale_parts[0], locale_parts[1].upper())
except UnknownLocaleError:
pass
try:
return format_currency(value, arg, locale=locale)
+1 -1
View File
@@ -156,7 +156,7 @@ def safelink_callback(attrs, new=False):
Makes sure that all links to a different domain are passed through a redirection handler
to ensure there's no passing of referers with secrets inside them.
"""
url = html.unescape(attrs.get((None, 'href'), '/'))
url = attrs.get((None, 'href'), '/')
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
-1
View File
@@ -95,7 +95,6 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'csp_report',
'widget',
'lead',
'scheduling',
]
+3 -16
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import pycountry
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext, pgettext, pgettext_lazy
@@ -30,7 +29,6 @@ from django_scopes import scope
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES
@@ -84,14 +82,14 @@ def _info(cc):
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return {
'data': [
{'name': gettext(s.name), 'code': s.code[3:]}
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
}
def _address_form(request):
def address_form(request):
cc = request.GET.get("country", "DE")
info = _info(cc)
@@ -111,7 +109,7 @@ def _address_form(request):
for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier}
if t.is_exclusive(event=event, country=country, is_business=is_business):
if t.exclusive:
info["transmission_types"] = [result]
break
else:
@@ -159,15 +157,4 @@ def _address_form(request):
# The help text explains that it is optional, so we want to hide that if it is required
info["vat_id"]["helptext_visible"] = False
return info
def address_form(request):
locale = request.GET.get('locale')
if locale in dict(settings.LANGUAGES):
with language(locale):
info = _address_form(request)
else:
info = _address_form(request)
return JsonResponse(info)
-6
View File
@@ -867,11 +867,6 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
"The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ."
),
"sum_by_net_only_business": _(
"Same as above, but only applied to business customers. Line-based rounding will be used for consumers. "
"Recommended when e-invoicing is only used for business customers and consumers do not receive "
"invoices. This can cause the payment amount to change when the invoice address is changed."
),
"sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct "
@@ -944,7 +939,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_show_payments',
'invoice_reissue_after_modify',
'invoice_generate',
'invoice_generate_only_business',
'invoice_period',
'invoice_attendee_name',
'invoice_event_location',
+6 -65
View File
@@ -57,9 +57,8 @@ from pretix.base.forms.widgets import (
from pretix.base.models import (
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
Voucher,
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel,
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
)
from pretix.base.signals import register_payment_providers
from pretix.base.timeframes import (
@@ -1316,10 +1315,10 @@ class QuestionAnswerFilterForm(forms.Form):
if date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
if d_start:
opqs = opqs.filter(subevent__date_from__gte=d_start)
if d_end:
opqs = opqs.filter(subevent__date_from__lt=d_end)
opqs = opqs.filter(
subevent__date_from__gte=d_start,
subevent__date_from__lt=d_end
)
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
if s != "":
@@ -2816,61 +2815,3 @@ class DeviceFilterForm(FilterForm):
qs = qs.order_by('-device_id')
return qs
class OutgoingMailFilterForm(FilterForm):
orders = {
'date': 'created',
'-date': '-created',
}
query = forms.CharField(
label=_('Search email address or subject'),
widget=forms.TextInput(attrs={
'placeholder': _('Search email address or subject'),
}),
required=False
)
event = forms.ModelChoiceField(
queryset=Event.objects.none(),
label=_('Event'),
empty_label=_('All events'),
required=False,
)
status = forms.ChoiceField(
label=_('Status'),
choices=[
('', _('All')),
*OutgoingMail.STATUS_CHOICES,
],
required=False
)
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['event'].queryset = request.organizer.events.all()
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(to__containsstring=query.lower())
| Q(cc__containsstring=query.lower())
| Q(bcc__containsstring=query.lower())
| Q(subject__icontains=query)
)
if fdata.get('event'):
qs = qs.filter(event=fdata['event'])
if fdata.get('status'):
qs = qs.filter(status=fdata['status'])
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by("-created", "-pk")
return qs
-17
View File
@@ -474,7 +474,6 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',
@@ -585,7 +584,6 @@ class MailSettingsForm(SettingsForm):
help_text=''.join([
str(_("All emails will be sent to this address as a Bcc copy.")),
str(_("You can specify multiple recipients separated by commas.")),
str(_("Sensitive emails like password resets will not be sent in Bcc.")),
]),
validators=[multimail_validate],
required=False,
@@ -635,16 +633,6 @@ class MailSettingsForm(SettingsForm):
required=False,
widget=I18nMarkdownTextarea,
)
mail_subject_customer_security_notice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_security_notice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
)
base_context = {
'mail_text_customer_registration': ['customer', 'url'],
@@ -653,8 +641,6 @@ class MailSettingsForm(SettingsForm):
'mail_subject_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'],
'mail_subject_customer_reset': ['customer', 'url'],
'mail_text_customer_security_notice': ['customer', 'url', 'message'],
'mail_subject_customer_security_notice': ['customer', 'url', 'message'],
}
def _get_sample_context(self, base_parameters):
@@ -668,9 +654,6 @@ class MailSettingsForm(SettingsForm):
'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30)
if 'message' in base_parameters:
placeholders['message'] = _('Your password has been changed.')
if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
-10
View File
@@ -170,12 +170,6 @@ class OrderFeeAdded(OrderChangeLogEntryType):
plain = _('A fee has been added')
@log_entry_types.new()
class OrderRecomputed(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.recomputed'
plain = _('Taxes and rounding have been recomputed')
@log_entry_types.new()
class OrderFeeChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.feevalue'
@@ -705,8 +699,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
@@ -801,8 +793,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.giftcards.created': _('The gift card has been created.'),
'pretix.giftcards.modified': _('The gift card has been changed.'),
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
'pretix.giftcards.transaction.payment': _('A payment has been performed.'),
'pretix.giftcards.transaction.refund': _('A refund has been performed. '),
'pretix.team.token.created': _('The token "{name}" has been created.'),
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
-9
View File
@@ -679,15 +679,6 @@ def get_organizer_navigation(request):
'active': (url.url_name == 'organizer.datasync.failedjobs'),
}])
nav.append({
'label': _('Outgoing emails'),
'url': reverse('control:organizer.outgoingmails', kwargs={
'organizer': request.organizer.slug,
}),
'active': 'organizer.outgoingmail' in url.url_name,
'icon': 'send',
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]),
@@ -12,7 +12,6 @@
<legend>{% trans "Invoice generation" %}</legend>
{% bootstrap_field form.invoice_generate layout="control" %}
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
{% bootstrap_field form.invoice_generate_only_business layout="control" %}
{% bootstrap_field form.invoice_email_attachment layout="control" %}
{% bootstrap_field form.invoice_email_organizer layout="control" %}
{% bootstrap_field form.invoice_language layout="control" %}
@@ -112,6 +111,11 @@
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
{% if t.exclusive %}
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
{% trans "(exclusive)" %}
</span>
{% endif %}
</span>
{% else %}
<span class="text-muted">
@@ -353,7 +353,7 @@
data-toggle="tooltip"
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
{% endif %}>
{% if order.status == "c" or not invoice_qualified %}
{% if order.status == "c" %}
{% trans "Generate cancellation" %}
{% else %}
{% trans "Cancel and reissue" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-4 col-md-5 col-xs-12">
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
@@ -73,9 +73,6 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
@@ -42,11 +42,7 @@
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
{% trans "Save" %}
</button>
</div>
{% else %}
@@ -132,7 +132,6 @@
<legend>{% trans "Customer accounts" %}</legend>
{% bootstrap_field sform.customer_accounts layout="control" %}
{% bootstrap_field sform.customer_accounts_native layout="control" %}
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-4 col-md-5 col-xs-12">
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
@@ -73,9 +73,6 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %}
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
@@ -43,11 +43,7 @@
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
{% trans "Save" %}
</button>
</div>
{% else %}
@@ -65,9 +65,6 @@
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
{% blocktrans asvar title_security_notice %}Customer account security notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="security_notice" title=title_security_notice items="mail_subject_customer_security_notice,mail_text_customer_security_notice" %}
</div>
</fieldset>
</div>
@@ -1,222 +0,0 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% load icon %}
{% load compress %}
{% load static %}
{% block inner %}
<h1>
{% trans "Outgoing email" %}
</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Email details" %}</h3>
</div>
<div class="panel-body">
<div class="row">
<div class="col-lg-7 col-md-12">
<dl class="dl-horizontal">
<dt>{% trans "From" context "email" %}</dt>
<dd>{{ sender }}</dd>
<dt>{% trans "To" context "email" %}</dt>
<dd>{{ mail.to|join:", " }}</dd>
{% if mail.cc %}
<dt>{% trans "Cc" context "email" %}</dt>
<dd>{{ mail.cc|join:", " }}</dd>
{% endif %}
{% if mail.bcc %}
<dt>{% trans "Bcc" context "email" %}</dt>
<dd>{{ mail.bcc|join:", " }}</dd>
{% endif %}
<dt>{% trans "Subject" %}</dt>
<dd>{{ mail.subject }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if mail.status == "queued" %}
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
{% elif mail.status == "inflight" %}
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
{% elif mail.status == "awaiting_retry" %}
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
{% elif mail.status == "failed" %}
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
{% elif mail.status == "bounced" %}
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
{% elif mail.status == "withheld" %}
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
{% elif mail.status == "aborted" %}
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
{% elif mail.status == "sent" %}
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
{% endif %}
</dd>
<dt>{% trans "Creation" %}</dt>
<dd>{{ mail.created|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% if mail.sent %}
<dt>{% trans "Sent" %}</dt>
<dd>{{ mail.sent|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if mail.retry_after and mail.status == "awaiting_retry" %}
<dt>{% trans "Next attempt (estimate)" %}</dt>
<dd>{{ mail.retry_after|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if mail.event %}
<dt>{% trans "Event" %}</dt>
<dd>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=mail.event.slug %}">
{{ mail.event }}
</a>
</dd>
{% endif %}
{% if mail.order %}
<dt>{% trans "Order" %}</dt>
<dd>
<a href="{% url "control:event.order" organizer=request.organizer.slug event=mail.event.slug code=mail.order.code %}">
{{ mail.order.code }}</a>{% if mail.orderposition %}-
{{ mail.orderposition.positionid }}{% endif %}
</dd>
{% endif %}
{% if mail.customer %}
<dt>{% trans "Customer" %}</dt>
<dd>
{% icon "user fa-fw" %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=mail.customer.identifier %}">
{{ mail.customer }}
</a>
</dd>
{% endif %}
</dl>
</div>
{% if mail.actual_attachments %}
<div class="col-lg-5 col-md-12">
<strong>{% trans "Attachments" %}</strong><br>
<ul class="list-unstyled">
{% for a in mail.actual_attachments %}
<li>
{% if a.type == "text/calendar" %}
{% icon "calendar-plus-o fa-fw" %}
{% elif a.type == "application/pdf" %}
{% icon "file-pdf-o fa-fw" %}
{% elif "image/" in a.type %}
{% icon "file-image-o fa-fw" %}
{% elif "msword" in a.type or "document" in a.type %}
{% icon "file-word-o fa-fw" %}
{% elif "excel" in a.type or "spreadsheet" in a.type %}
{% icon "file-excel-o fa-fw" %}
{% elif "powerpoint" in a.type or "presentation" in a.type %}
{% icon "file-powerpoint-o fa-fw" %}
{% elif "pkpass" in a.type %}
{% icon "qrcode fa-fw" %}
{% else %}
{% icon "file-o fa-fw" %}
{% endif %}
{{ a.name }}
<span class="text-muted">
({{ a.size|filesizeformat }})
</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div>
<ul class="nav nav-tabs" role="tablist">
{% if mail.is_failed %}
<li role="presentation" class="active">
<a href="#tab-error" role="tab" data-toggle="tab">
<span class="fa fa-warning"></span>
{% trans "Error" %}
</a>
</li>
{% endif %}
{% if mail.body_html %}
<li role="presentation"
{% if not mail.is_failed %}class="active"{% endif %}>
<a href="#tab-html" role="tab" data-toggle="tab">
<span class="fa fa-eye"></span>
{% trans "HTML content" %}
</a>
</li>
{% endif %}
<li role="presentation"
{% if not mail.is_failed and not mail.body_html %}class="active"{% endif %}>
<a href="#tab-text" role="tab" data-toggle="tab">
<span class="fa fa-file-text-o"></span>
{% trans "Text content" %}
</a>
</li>
<li role="presentation">
<a href="#tab-headers" role="tab" data-toggle="tab">
<span class="fa fa-code"></span>
{% trans "Headers" %}
</a>
</li>
</ul>
<div class="tab-content">
{% if mail.is_failed %}
<div role="tabpanel" class="tab-pane active" id="tab-error">
<strong>
{{ mail.error }}
</strong>
<pre>{{ mail.error_detail }}</pre>
</div>
{% endif %}
{% if mail.body_html %}
<div role="tabpanel"
class="tab-pane {% if not mail.is_failed %}active{% endif %}"
id="tab-html">
{% if mail.sensitive %}
<div class="empty-collection">
<p>
{% icon "eye-slash fa-4x" %}
</p>
<p>
{% blocktrans trimmed %}
Sensitive content not shown for security reasons
{% endblocktrans %}
</p>
</div>
{% else %}
{{ data_url|json_script:"mail_body_html" }}
{% endif %}
</div>
{% endif %}
<div role="tabpanel"
class="tab-pane {% if not mail.is_failed and not mail.body_html %}active{% endif %}"
id="tab-text">
{% if mail.sensitive %}
<div class="empty-collection">
<p>
{% icon "eye-slash fa-4x" %}
</p>
<p>
{% blocktrans trimmed %}
Sensitive content not shown for security reasons
{% endblocktrans %}
</p>
</div>
{% else %}
<pre><code>{{ mail.body_plain }}</code></pre>
{% endif %}
</div>
<div role="tabpanel"
class="tab-pane"
id="tab-headers">
<pre><code>{% for k, v in mail.headers.items %}{{ k }}: {{ v }}<br>{% endfor %}</code></pre>
<p class="text-muted">
{% trans "Additional headers will be added by the mail server and are not visible here." %}
</p>
</div>
</div>
</div>
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/outgoingmail.js" %}"></script>
{% endcompress %}
{% endblock %}
@@ -1,185 +0,0 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% load icon %}
{% block inner %}
<h1>
{% trans "Outgoing emails" %}
</h1>
<p>
{% blocktrans trimmed with days=days %}
This is an overview of all emails sent by your organizer account in the last {{ days }} days.
{% endblocktrans %}
</p>
{% if mails|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't sent any emails recently.
{% endblocktrans %}
</p>
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Filter" %}</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-md-4 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
<div class="col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.status %}
</div>
<div class="col-md-5 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.event %}
</div>
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
<form action="{% url "control:organizer.outgoingmails.bulk_action" organizer=request.organizer.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-quotas">
<thead>
<tr>
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
<th>{% trans "Subject" %}</th>
<th>{% trans "Recipients" %}</th>
<th>{% trans "Context" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Date" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th></th>
</tr>
{% if page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="7">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for m in mails %}
<tr>
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="outgoingmail"
class="batch-select-checkbox"
value="{{ m.pk }}"/></label>
</td>
<td>
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}">
{{ m.subject }}
</a>
{% if m.sensitive %}
<span class="text-muted">{% icon "eye-slash" %}</span>
{% endif %}
</td>
<td>
{{ m.to|join:", " }}
{% if m.cc %}
<br><small class="text-muted">{% trans "Cc" context "email" %}: {{ m.cc|join:", " }}</small>
{% endif %}
{% if m.bcc %}
<br><small class="text-muted">{% trans "Bcc" context "email" %}: {{ m.bcc|join:", " }}</small>
{% endif %}
</td>
<td>
{% if m.event %}
<div>
{% icon "calendar fa-fw" %}
<a href="{% url "control:event.index" organizer=request.organizer.slug event=m.event.slug %}">
{{ m.event }}
</a>
</div>
{% endif %}
{% if m.order %}
<div>
{% icon "shopping-cart fa-fw" %}
<a href="{% url "control:event.order" organizer=request.organizer.slug event=m.event.slug code=m.order.code %}">
{{ m.order.code }}</a>{% if m.orderposition %}-{{ m.orderposition.positionid }}{% endif %}
</div>
{% endif %}
{% if m.customer %}
<div>
{% icon "user fa-fw" %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
{{ m.customer }}
</a>
</div>
{% endif %}
</td>
<td>
{% if m.status == "queued" %}
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
{% elif m.status == "inflight" %}
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
{% elif m.status == "awaiting_retry" %}
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
{% elif m.status == "failed" %}
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
{% elif m.status == "bounced" %}
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
{% elif m.status == "withheld" %}
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
{% elif m.status == "aborted" %}
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
{% elif m.status == "sent" %}
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
{% endif %}
</td>
<td>
{{ m.created|date:"SHORT_DATETIME_FORMAT" }}
{% if m.sent %}
<br>
<small class="text-muted">{% trans "Sent:" %} {{ m.sent|date:"SHORT_DATETIME_FORMAT" }}</small>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}"
class="btn btn-default btn-sm">{% icon "eye" %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="batch-select-actions">
<button type="submit" class="btn btn-primary btn-save" name="action" value="retry">
{% icon "repeat" %}
{% trans "Retry (if failed or withheld)" %}
</button>
<button type="submit" class="btn btn-danger btn-save" name="action" value="abort">
{% icon "ban" %}
{% trans "Abort (if queued, awaiting retry or withheld)" %}
</button>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}
+2 -6
View File
@@ -38,9 +38,8 @@ from django.views.generic.base import RedirectView
from pretix.control.views import (
auth, checkin, dashboards, datasync, discounts, event, geo,
global_settings, item, mail, main, modelimport, oauth, orders, organizer,
pdf, search, shredder, subevents, typeahead, user, users, vouchers,
waitinglist,
global_settings, item, main, modelimport, oauth, orders, organizer, pdf,
search, shredder, subevents, typeahead, user, users, vouchers, waitinglist,
)
urlpatterns = [
@@ -241,9 +240,6 @@ urlpatterns = [
name='organizer.gate.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(),
name='organizer.gate.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmails$', mail.OutgoingMailListView.as_view(), name='organizer.outgoingmails'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/bulk_action$', mail.OutgoingMailBulkAction.as_view(), name='organizer.outgoingmails.bulk_action'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/(?P<mail>[0-9]+)/$', mail.OutgoingMailDetailView.as_view(), name='organizer.outgoingmail'),
re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
+5 -6
View File
@@ -57,7 +57,6 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView
from django_otp import match_token
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge
from pretix.base.auth import get_auth_backends
@@ -66,6 +65,7 @@ from pretix.base.forms.auth import (
)
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.base.services.mail import SendMailException
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.security import handle_login_source
@@ -346,6 +346,9 @@ class Forgot(TemplateView):
except User.DoesNotExist:
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException:
logger.exception('Sending password reset email to \"' + email + '\" failed.')
except RepeatedResetDenied:
pass
@@ -360,7 +363,7 @@ class Forgot(TemplateView):
else:
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
return redirect('control:auth.forgot')
return redirect('control:auth.forgot')
else:
return self.get(request, *args, **kwargs)
@@ -535,10 +538,6 @@ class Login2FAView(TemplateView):
break
else:
valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
@@ -188,8 +188,6 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
return None, None
try:
for k, v in pkg.metadata.items():
if k == "License-Expression":
license = v
if k == "License":
license = v
if k == "Home-page":
-194
View File
@@ -1,194 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import base64
import logging
from email.header import decode_header, make_header
from email.utils import parseaddr
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import BadRequest
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ngettext
from django.views import View
from django.views.generic import DetailView, ListView
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.models import OutgoingMail
from pretix.base.services.mail import mail_send_task
from pretix.control.forms.filter import OutgoingMailFilterForm
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.views.organizer import OrganizerDetailViewMixin
logger = logging.getLogger(__name__)
class OutgoingMailQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
d = self.request.POST
else:
d = self.request.GET
d = d.copy()
return d
@cached_property
def filter_form(self):
return OutgoingMailFilterForm(
data=self.request_data,
request=self.request,
)
def get_queryset(self):
qs = self.request.organizer.outgoing_mails.select_related(
'event', 'order', 'orderposition', 'customer'
)
if 'outgoingmail' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('outgoingmail')
)
elif self.request.method == 'GET' or '__ALL' in self.request_data:
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
else:
raise BadRequest("No mails selected")
return qs
class OutgoingMailListView(OutgoingMailQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = OutgoingMail
template_name = 'pretixcontrol/organizers/outgoing_mails.html'
# Assume "the highest" permission level for now because emails could belog to any event, order, or customer.
# We plan to add a special permissoin in the future
permission = 'can_change_organizer_settings'
context_object_name = 'mails'
paginate_by = 100
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
ctx['days'] = int(settings.OUTGOING_MAIL_RETENTION / (24 * 3600))
return ctx
class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
model = OutgoingMail
template_name = 'pretixcontrol/organizers/outgoing_mail.html'
permission = 'can_change_organizer_settings'
context_object_name = 'mail'
def get_object(self, queryset=None):
return get_object_or_404(OutgoingMail, organizer=self.request.organizer, pk=self.kwargs.get('mail'))
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
csps = {
'frame-src': ['data:'],
# Unfortuantely, we can't avoid unsafe-inline for style here.
# See outgoingmail.js for the protection measures we take.
'style-src': ["'unsafe-inline'"],
}
_merge_csp(h, csps)
response['Content-Security-Policy'] = _render_csp(h)
return response
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if self.object.body_html:
ctx['data_url'] = "data:text/html;charset=utf-8;base64," + base64.b64encode(self.object.body_html.encode()).decode()
from_name, from_email = parseaddr(self.object.sender)
if from_name:
from_name = make_header(decode_header(from_name))
ctx['sender'] = "{} <{}>".format(from_name, from_email) if from_name else from_email
return ctx
class OutgoingMailBulkAction(OutgoingMailQueryMixin, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, View):
permission = 'can_change_organizer_settings'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'retry':
ids = set(
self.get_queryset().filter(status__in=OutgoingMail.STATUS_LIST_RETRYABLE).values_list("pk", flat=True)
)
with transaction.atomic():
OutgoingMail.objects.filter(pk__in=ids).update(
status=OutgoingMail.STATUS_QUEUED,
sent=None,
)
self.request.organizer.log_action(
'pretix.organizer.outgoingmails.retried', user=self.request.user, data={
'mails': list(ids)
}, save=False
)
for i in ids:
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
messages.success(request, ngettext(
"A retry of one email was scheduled.",
"A retry of {num} emails was scheduled.",
len(ids),
).format(num=len(ids)))
elif request.POST.get('action') == 'abort':
ids = set(
self.get_queryset().filter(
status__in=(OutgoingMail.STATUS_QUEUED, OutgoingMail.STATUS_AWAITING_RETRY)
).values_list("pk", flat=True)
)
with transaction.atomic():
OutgoingMail.objects.filter(pk__in=ids).update(
status=OutgoingMail.STATUS_ABORTED,
sent=None,
)
self.request.organizer.log_action(
'pretix.organizer.outgoingmails.aborted', user=self.request.user, data={
'mails': list(ids)
}, save=False
)
for i in ids:
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
messages.success(request, ngettext(
"One email was aborted and will not be sent.",
"{num} emails were aborted and will not be sent.",
len(ids),
).format(num=len(ids)))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:organizer.outgoingmails', kwargs={
'organizer': self.request.organizer.slug,
})
+79 -76
View File
@@ -33,7 +33,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import json
import logging
import mimetypes
@@ -98,7 +97,9 @@ from pretix.base.services.invoices import (
invoice_qualified, regenerate_invoice, transmit_invoice,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import prefix_subject, render_mail
from pretix.base.services.mail import (
SendMailException, prefix_subject, render_mail,
)
from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
@@ -507,10 +508,9 @@ class OrderView(EventPermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['invoice_qualified'] = invoice_qualified(self.order)
ctx['can_generate_invoice'] = ctx['invoice_qualified'] and (
ctx['can_generate_invoice'] = invoice_qualified(self.order) and (
self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True')
) and (
) and self.order.status in (Order.STATUS_PAID, Order.STATUS_PENDING) and (
not self.order.invoices.exists()
or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
)
@@ -1064,6 +1064,10 @@ class OrderPaymentConfirm(OrderView):
messages.error(self.request, str(e))
except PaymentException as e:
messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request,
_('The payment has been marked as complete, but we were unable to send a '
'confirmation mail.'))
else:
messages.success(self.request, _('The payment has been marked as complete.'))
else:
@@ -1226,11 +1230,7 @@ class OrderRefundView(OrderView):
customer=order.customer,
testmode=order.testmode
)
giftcard.log_action(
action='pretix.giftcards.created',
user=self.request.user,
data={}
)
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={})
refunds.append(OrderRefund(
order=order,
payment=None,
@@ -1538,6 +1538,9 @@ class OrderTransition(OrderView):
'message': str(e)
})
messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
'confirmation mail.'))
else:
messages.success(self.request, _('The payment has been created successfully.'))
elif self.order.cancel_allowed() and to == 'c':
@@ -1738,15 +1741,14 @@ class OrderInvoiceReissue(OrderView):
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
else:
c = generate_cancellation(inv)
if invoice_qualified(order):
if order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
inv = generate_invoice(order)
messages.success(self.request, _('The invoice has been reissued.'))
else:
inv = c
messages.success(self.request, _('The invoice has been canceled.'))
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
'invoice': inv.pk
})
messages.success(self.request, _('The invoice has been reissued.'))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA
@@ -1776,11 +1778,15 @@ class OrderResendLink(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
if 'position' in kwargs:
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
p.resend_link(user=self.request.user)
else:
self.order.resend_link(user=self.request.user)
try:
if 'position' in kwargs:
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
p.resend_link(user=self.request.user)
else:
self.order.resend_link(user=self.request.user)
except SendMailException:
messages.error(self.request, _('There was an error sending the mail. Please try again later.'))
return redirect(self.get_order_url())
messages.success(self.request, _('The email has been queued to be sent.'))
return redirect(self.get_order_url())
@@ -2424,18 +2430,24 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
}
return self.get(self.request, *self.args, **self.kwargs)
else:
order.send_mail(
form.cleaned_data['subject'], email_template,
email_context, 'pretix.event.order.email.custom_sent',
self.request.user, auto_email=False,
attach_tickets=form.cleaned_data.get('attach_tickets', False),
invoices=form.cleaned_data.get('attach_invoices', []),
attach_other_files=[a for a in [
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
)
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(order.email)))
try:
order.send_mail(
form.cleaned_data['subject'], email_template,
email_context, 'pretix.event.order.email.custom_sent',
self.request.user, auto_email=False,
attach_tickets=form.cleaned_data.get('attach_tickets', False),
invoices=form.cleaned_data.get('attach_invoices', []),
attach_other_files=[a for a in [
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
)
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(order.email)))
except SendMailException:
messages.error(
self.request,
_('Failed to send mail to the following user: {}'.format(order.email))
)
return super(OrderSendMail, self).form_valid(form)
def get_success_url(self):
@@ -2488,19 +2500,23 @@ class OrderPositionSendMail(OrderSendMail):
}
return self.get(self.request, *self.args, **self.kwargs)
else:
position.send_mail(
form.cleaned_data['subject'],
email_template,
email_context,
'pretix.event.order.position.email.custom_sent',
self.request.user,
attach_tickets=form.cleaned_data.get('attach_tickets', False),
attach_other_files=[a for a in [
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
)
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
try:
position.send_mail(
form.cleaned_data['subject'],
email_template,
email_context,
'pretix.event.order.position.email.custom_sent',
self.request.user,
attach_tickets=form.cleaned_data.get('attach_tickets', False),
attach_other_files=[a for a in [
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
)
messages.success(self.request,
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
except SendMailException:
messages.error(self.request,
_('Failed to send mail to the following user: {}'.format(position.attendee_email)))
return super(OrderSendMail, self).form_valid(form)
@@ -2661,8 +2677,8 @@ class ExportMixin:
if id != ex.identifier:
continue
if self.scheduled or self.scheduled_copy_from:
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
if self.scheduled:
initial = dict(self.scheduled.export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
@@ -2705,11 +2721,6 @@ class ExportMixin:
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['exporters'] = self.exporters
@@ -2779,16 +2790,7 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2827,8 +2829,6 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else:
initial = {}
return RRuleForm(
@@ -2839,15 +2839,11 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@cached_property
def schedule_form(self):
if self.scheduled_copy_from:
instance = copy.copy(self.scheduled_copy_from)
instance.pk = None
else:
instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled and not self.scheduled_copy_from:
instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
@@ -2872,11 +2868,18 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
if "schedule" in self.request.POST or self.scheduled:
if "schedule" in self.request.POST and not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter:
for s in ctx['scheduled']:
try:
+59 -95
View File
@@ -32,7 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import json
import logging
import re
@@ -103,7 +102,7 @@ from pretix.base.plugins import (
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.export import multiexport, scheduled_organizer_export
from pretix.base.services.mail import mail, prefix_subject
from pretix.base.services.mail import SendMailException, mail, prefix_subject
from pretix.base.signals import register_multievent_data_exporters
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction
@@ -1037,21 +1036,24 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
return ctx
def _send_invite(self, instance):
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.request.organizer.name,
'team': instance.team.name,
'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=self.request.LANGUAGE_CODE
)
try:
mail(
instance.email,
_('pretix account invitation'),
'pretixcontrol/email/invitation.txt',
{
'user': self,
'organizer': self.request.organizer.name,
'team': instance.team.name,
'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token
})
},
event=None,
locale=self.request.LANGUAGE_CODE
)
except SendMailException:
pass # Already logged
@transaction.atomic
def post(self, request, *args, **kwargs):
@@ -1667,12 +1669,9 @@ class GiftCardAcceptanceInviteView(OrganizerDetailViewMixin, OrganizerPermission
active=False,
)
self.request.organizer.log_action(
action='pretix.giftcards.acceptance.acceptor.invited',
data={
'acceptor': form.cleaned_data['acceptor'].slug,
'issuer': self.request.organizer.slug,
'reusable_media': form.cleaned_data['reusable_media']
},
'pretix.giftcards.acceptance.acceptor.invited',
data={'acceptor': form.cleaned_data['acceptor'].slug,
'reusable_media': form.cleaned_data['reusable_media']},
user=self.request.user
)
messages.success(self.request, _('The selected organizer has been invited.'))
@@ -1708,11 +1707,8 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).delete()
if done:
self.request.organizer.log_action(
action='pretix.giftcards.acceptance.acceptor.removed',
data={
'acceptor': request.POST.get("delete_acceptor"),
'issuer': self.request.organizer.slug
},
'pretix.giftcards.acceptance.acceptor.removed',
data={'acceptor': request.POST.get("delete_acceptor")},
user=request.user
)
messages.success(self.request, _('The selected connection has been removed.'))
@@ -1722,11 +1718,8 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).delete()
if done:
self.request.organizer.log_action(
action='pretix.giftcards.acceptance.issuer.removed',
data={
'issuer': request.POST.get("delete_acceptor"),
'acceptor': self.request.organizer.slug
},
'pretix.giftcards.acceptance.issuer.removed',
data={'issuer': request.POST.get("delete_acceptor")},
user=request.user
)
messages.success(self.request, _('The selected connection has been removed.'))
@@ -1736,11 +1729,8 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).update(active=True)
if done:
self.request.organizer.log_action(
action='pretix.giftcards.acceptance.issuer.accepted',
data={
'issuer': request.POST.get("accept_issuer"),
'acceptor': self.request.organizer.slug
},
'pretix.giftcards.acceptance.issuer.accepted',
data={'issuer': request.POST.get("accept_issuer")},
user=request.user
)
messages.success(self.request, _('The selected connection has been accepted.'))
@@ -1846,11 +1836,10 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
acceptor=request.organizer,
)
self.object.log_action(
action='pretix.giftcards.transaction.manual',
'pretix.giftcards.transaction.manual',
data={
'value': value,
'text': request.POST.get('text'),
'acceptor_id': self.request.organizer.id
'text': request.POST.get('text')
},
user=self.request.user,
)
@@ -1899,23 +1888,15 @@ class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
messages.success(self.request, _('The gift card has been created and can now be used.'))
form.instance.issuer = self.request.organizer
super().form_valid(form)
form.instance.log_action(
action='pretix.giftcards.created',
user=self.request.user,
form.instance.transactions.create(
acceptor=self.request.organizer,
value=form.cleaned_data['value']
)
form.instance.log_action('pretix.giftcards.created', user=self.request.user, data={})
if form.cleaned_data['value']:
form.instance.transactions.create(
acceptor=self.request.organizer,
value=form.cleaned_data['value']
)
form.instance.log_action(
action='pretix.giftcards.transaction.manual',
user=self.request.user,
data={
'value': form.cleaned_data['value'],
'acceptor_id': self.request.organizer.id
}
)
form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={
'value': form.cleaned_data['value']
})
return redirect(reverse(
'control:organizer.giftcard',
kwargs={
@@ -1943,11 +1924,7 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
def form_valid(self, form):
messages.success(self.request, _('The gift card has been changed.'))
super().form_valid(form)
form.instance.log_action(
action='pretix.giftcards.modified',
user=self.request.user,
data=dict(form.cleaned_data)
)
form.instance.log_action('pretix.giftcards.modified', user=self.request.user, data=dict(form.cleaned_data))
return redirect(reverse(
'control:organizer.giftcard',
kwargs={
@@ -1966,8 +1943,8 @@ class ExportMixin:
for ex in self.exporters:
if id != ex.identifier:
continue
if self.scheduled or self.scheduled_copy_from:
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
if self.scheduled:
initial = dict(self.scheduled.export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
@@ -2070,11 +2047,6 @@ class ExportMixin:
elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
known_errortypes = ['ExportError', 'ExportEmptyError']
@@ -2141,16 +2113,7 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2188,8 +2151,6 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self):
if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else:
initial = {}
return RRuleForm(
@@ -2201,15 +2162,11 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@cached_property
def schedule_form(self):
if self.scheduled_copy_from:
instance = copy.copy(self.scheduled_copy_from)
instance.pk = None
else:
instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=str(get_current_timezone()),
)
instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if not self.scheduled:
initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
@@ -2242,10 +2199,18 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
if "schedule" in self.request.POST or self.scheduled:
if "schedule" in self.request.POST and not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter:
for s in ctx['scheduled']:
try:
@@ -2618,7 +2583,7 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
def get_queryset(self):
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
# bear the performance cost
qs = self.request.organizer.logentry_set.filter(event=None).select_related(
qs = self.request.organizer.all_logentries().select_related(
'user', 'content_type', 'api_token', 'oauth_application', 'device'
).order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
@@ -3049,7 +3014,6 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
locale=self.customer.locale,
customer=self.customer,
organizer=self.request.organizer,
sensitive=True,
)
messages.success(
self.request,
+6 -5
View File
@@ -41,6 +41,7 @@ from hijack import signals
from pretix.base.auth import get_auth_backends
from pretix.base.models import User
from pretix.base.services.mail import SendMailException
from pretix.control.forms.filter import UserFilterForm
from pretix.control.forms.users import UserEditForm
from pretix.control.permissions import AdministratorPermissionRequiredMixin
@@ -138,7 +139,11 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
self.object.send_password_reset()
try:
self.object.send_password_reset()
except SendMailException:
messages.error(request, _('There was an error sending the mail. Please try again later.'))
return redirect(self.get_success_url())
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
user=request.user)
@@ -160,10 +165,6 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
self.object.send_security_notice([
_('A two-factor emergency code has been generated by a system administrator. This will usually happen '
'if you lost access to your two-factor credentials and requested a reset of the credentials.')
])
messages.success(request, _(
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
-16
View File
@@ -136,19 +136,3 @@ custom_translations = [
gettext_noop("North Macedonia"),
gettext_noop("Macao"),
]
def pycountry_add(db, **kw):
# Workaround for https://github.com/pycountry/pycountry/issues/281
db._load()
obj = db.factory(**kw)
db.objects.append(obj)
for key, value in kw.items():
if key in db.no_index:
continue
value = value.lower()
index = db.indices.setdefault(key, {})
if key in ["country_code"]:
index.setdefault(value, set()).add(obj)
else:
index[value] = obj
+1 -14
View File
@@ -25,7 +25,7 @@ from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction
from django.db.models import (
Aggregate, Expression, F, Field, JSONField, Lookup, OrderBy, Value,
Aggregate, Expression, F, Field, Lookup, OrderBy, Value,
)
from django.utils.functional import lazy
@@ -154,19 +154,6 @@ class NotEqual(Lookup):
return '%s <> %s' % (lhs, rhs), params
@JSONField.register_lookup
class ContainsString(Lookup):
lookup_name = 'containsstring'
def as_sql(self, compiler, connection):
if connection.vendor != "postgresql":
raise NotImplementedError("Lookup in JSON Array not supported on this database")
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
params = lhs_params + rhs_params
return '%s ? %s' % (lhs, rhs), params
class PostgresWindowFrame(Expression):
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
+2 -25
View File
@@ -22,7 +22,6 @@
import logging
from string import Formatter
from django.core.exceptions import SuspiciousOperation
from django.utils.html import conditional_escape
logger = logging.getLogger(__name__)
@@ -38,17 +37,6 @@ class PlainHtmlAlternativeString:
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
class FormattedString(str):
"""
A str subclass that has been specifically marked as "already formatted" for email rendering
purposes to avoid duplicate formatting.
"""
__slots__ = ()
def __str__(self):
return self
class SafeFormatter(Formatter):
"""
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
@@ -89,19 +77,8 @@ class SafeFormatter(Formatter):
# Ignore format_spec
return super().format_field(self._prepare_value(value), '')
def convert_field(self, value, conversion):
# Ignore any conversions
if conversion is None:
return value
else:
return str(value)
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None) -> FormattedString:
if isinstance(template, FormattedString):
raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.")
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
if not isinstance(template, str):
template = str(template)
return FormattedString(
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
)
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
+19 -16
View File
@@ -32,7 +32,7 @@ from django_countries.fields import Country
from geoip2.errors import AddressNotFoundError
from pretix.base.i18n import language
from pretix.base.services.mail import mail
from pretix.base.services.mail import SendMailException, mail
from pretix.helpers.http import get_client_ip
from pretix.helpers.urls import build_absolute_uri
@@ -159,18 +159,21 @@ def handle_login_source(user, request):
})
if user.known_login_sources.count() > 1:
# Do not send on first login or first login after introduction of this feature:
with language(user.locale):
mail(
user.email,
_('Login from new source detected'),
'pretixcontrol/email/login_notice.txt',
{
'source': src,
'country': Country(str(country)).name if country else _('Unknown country'),
'instance': settings.PRETIX_INSTANCE_NAME,
'url': build_absolute_uri('control:user.settings')
},
event=None,
user=user,
locale=user.locale
)
try:
with language(user.locale):
mail(
user.email,
_('Login from new source detected'),
'pretixcontrol/email/login_notice.txt',
{
'source': src,
'country': Country(str(country)).name if country else _('Unknown country'),
'instance': settings.PRETIX_INSTANCE_NAME,
'url': build_absolute_uri('control:user.settings')
},
event=None,
user=user,
locale=user.locale
)
except SendMailException:
pass # Not much we can do
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,26 +149,16 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr ""
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -153,26 +153,16 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "طلبات مختارة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "الطلبات المدفوعة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "إجمالي الإيرادات"
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,26 +149,16 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr ""
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
@@ -150,26 +150,16 @@ msgid "Payment method unavailable"
msgstr "El mètode de pagament no està disponible"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "Comanda realitzada"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "Comandes pagades"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "Facturació total"
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2026-01-08 04:00+0000\n"
"Last-Translator: Jiří Pastrňák <jiri@pastrnak.email>\n"
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -150,26 +150,16 @@ msgid "Payment method unavailable"
msgstr "Způsob platby není k dispozici"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "Zadané objednávky"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "Zaplacené objednávky"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "Celkové příjmy"
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -150,26 +150,16 @@ msgid "Payment method unavailable"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr ""
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2024-07-10 15:00+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -154,26 +154,16 @@ msgid "Payment method unavailable"
msgstr "Betalingsmetode er ikke tilgængelig"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "Afgivne bestillinger"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "Betalte bestillinger"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "Omsætning i alt"
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2025-10-29 09:24+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -150,26 +150,16 @@ msgid "Payment method unavailable"
msgstr "Zahlungsmethode nicht verfügbar"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "Getätigte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "Bezahlte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "Gesamtumsatz"
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
"PO-Revision-Date: 2025-10-29 09:24+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
@@ -150,26 +150,16 @@ msgid "Payment method unavailable"
msgstr "Zahlungsmethode nicht verfügbar"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Placed orders"
msgstr "Getätigte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Paid orders"
msgstr "Bezahlte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue"
msgstr "Gesamtumsatz"

Some files were not shown because too many files have changed in this diff Show More