Merge remote-tracking branch 'origin/master' into cross-selling

# Conflicts:
#	src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html
This commit is contained in:
Mira Weller
2024-08-29 13:49:28 +02:00
220 changed files with 119314 additions and 98078 deletions

View File

@@ -207,10 +207,13 @@ class ListExporter(BaseExporter):
def get_filename(self):
return 'export'
def get_csv_encoding(self):
return 'utf-8'
def _render_csv(self, form_data, output_file=None, **kwargs):
if output_file:
if 'b' in output_file.mode:
output_file = io.TextIOWrapper(output_file, encoding='utf-8', newline='')
output_file = io.TextIOWrapper(output_file, encoding=self.get_csv_encoding(), errors='replace', newline='')
writer = csv.writer(output_file, **kwargs)
total = 0
counter = 0
@@ -246,7 +249,7 @@ class ListExporter(BaseExporter):
if counter % max(10, total // 100) == 0:
self.progress_callback(counter / total * 100)
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode(self.get_csv_encoding(), errors='replace')
def prepare_xlsx_sheet(self, ws):
pass
@@ -256,7 +259,7 @@ class ListExporter(BaseExporter):
ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws)
try:
ws.title = str(self.verbose_name)
ws.title = str(self.verbose_name)[:30]
except:
pass
total = 0
@@ -374,7 +377,7 @@ class MultiSheetListExporter(ListExporter):
wb = SafeWorkbook(write_only=True)
n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l))
ws = wb.create_sheet(str(l)[:30])
if hasattr(self, 'prepare_xlsx_sheet_' + s):
getattr(self, 'prepare_xlsx_sheet_' + s)(ws)

View File

@@ -560,7 +560,7 @@ class OrderListExporter(MultiSheetListExporter):
),
).select_related(
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule'
'voucher', 'tax_rule', 'addon_to',
).prefetch_related(
'subevent', 'subevent__meta_values',
'answers', 'answers__question', 'answers__options'
@@ -619,6 +619,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Valid until'),
_('Order comment'),
_('Follow-up date'),
_('Add-on to position ID'),
]
questions = list(Question.objects.filter(event__in=self.events))
@@ -652,7 +653,8 @@ class OrderListExporter(MultiSheetListExporter):
_('VAT ID'),
]
headers += [
_('Sales channel'), _('Order locale'),
_('Sales channel'),
_('Order locale'),
_('E-mail address verified'),
_('External customer ID'),
_('Check-in lists'),
@@ -743,6 +745,7 @@ class OrderListExporter(MultiSheetListExporter):
]
row.append(order.comment)
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
row.append(op.addon_to.positionid if op.addon_to_id else "")
acache = {}
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead

View File

@@ -38,6 +38,7 @@ from datetime import datetime
from django import forms
from django.utils.formats import get_format
from django.utils.functional import lazy
from django.utils.html import escape
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _
@@ -64,7 +65,7 @@ def format_placeholders_help_text(placeholders, event=None):
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
placeholders.sort(key=lambda x: x[0])
phs = [
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (_("Sample: %s") % v if v else "", k)
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(_("Sample: %s") % v) if v else "", escape(k))
for k, v in placeholders
]
return _('Available placeholders: {list}').format(

View File

@@ -30,7 +30,7 @@ from typing import Tuple
import bleach
import vat_moss.exchange_rates
from bidi.algorithm import get_display
from bidi import get_display
from django.contrib.staticfiles import finders
from django.db.models import Sum
from django.dispatch import receiver

View File

@@ -102,9 +102,9 @@ class CheckinList(LoggedModel):
auto_checkin_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_('Sales channels to automatically check in'),
help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
'any of the selected sales channels. This option can be useful when tickets sold at the box office '
'are not checked again before entry and should be considered validated directly upon purchase.'),
help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin '
'"Auto check-in" can be used. When we remove this option, we will automatically migrate your event '
'to use the new plugin.'),
blank=True,
)
rules = models.JSONField(default=dict, blank=True)

View File

@@ -60,7 +60,6 @@ from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@@ -180,14 +179,10 @@ class EventMixin:
"""
tz = tz or self.timezone
if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
if as_html:
return format_html(
"<time datetime=\"{}\">{}</time>",
_date(self.date_from.astimezone(tz), "Y-m-d"),
_date(self.date_from.astimezone(tz), "DATE_FORMAT"),
)
return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz), as_html)
df, dt = self.date_from, self.date_from
else:
df, dt = self.date_from, self.date_to
return daterange(df.astimezone(tz), dt.astimezone(tz), as_html)
def get_date_range_display_as_html(self, tz=None, force_show_end=False) -> str:
return self.get_date_range_display(tz, force_show_end, as_html=True)

View File

@@ -185,7 +185,7 @@ class Seat(models.Model):
@classmethod
def annotated(cls, qs, event_id, subevent, ignore_voucher_id=None, minimal_distance=0,
ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False):
ignore_order_id=None, ignore_cart_id=None, distance_only_within_row=False, annotate_ids=False):
from . import CartPosition, Order, OrderPosition, Voucher
vqs = Voucher.objects.filter(
@@ -214,17 +214,24 @@ class Seat(models.Model):
)
if ignore_cart_id:
cqs = cqs.exclude(cart_id=ignore_cart_id)
qs_annotated = qs.annotate(
has_order=Exists(
opqs
),
has_cart=Exists(
cqs
),
has_voucher=Exists(
vqs
if annotate_ids:
qs_annotated = qs.annotate(
orderposition_id=Subquery(opqs.values('id')),
cartposition_id=Subquery(cqs.values('id')),
voucher_id=Subquery(vqs.values('id')),
)
else:
qs_annotated = qs.annotate(
has_order=Exists(
opqs
),
has_cart=Exists(
cqs
),
has_voucher=Exists(
vqs
)
)
)
if minimal_distance > 0:
# TODO: Is there a more performant implementation on PostgreSQL using
@@ -235,7 +242,11 @@ class Seat(models.Model):
Power(F('y') - OuterRef('y'), Value(2), output_field=models.FloatField())
)
).filter(
Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True),
(
(Q(orderposition_id__isnull=False) | Q(cartposition_id__isnull=False) | Q(voucher_id__isnull=False))
if annotate_ids else
(Q(has_order=True) | Q(has_cart=True) | Q(has_voucher=True))
),
distance__lt=minimal_distance ** 2
)
if distance_only_within_row:

View File

@@ -587,7 +587,7 @@ class BasePaymentProvider:
return rel_date.datetime(self.event).date()
def _is_available_by_time(self, now_dt=None, cart_id=None, order=None):
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
tz = ZoneInfo(self.event.settings.timezone)
try:

View File

@@ -49,7 +49,7 @@ from io import BytesIO
import jsonschema
import reportlab.rl_config
from bidi.algorithm import get_display
from bidi import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
@@ -956,7 +956,7 @@ class Renderer:
)
canvas.restoreState()
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None):
font = o['fontfamily']
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
@@ -970,12 +970,13 @@ class Renderer:
if o['italic']:
font += ' I'
fontsize = override_fontsize if override_fontsize is not None else float(o['fontsize'])
try:
ad = getAscentDescent(font, float(o['fontsize']))
ad = getAscentDescent(font, fontsize)
except KeyError: # font not known, fall back
logger.warning(f'Use of unknown font "{font}"')
font = 'Open Sans'
ad = getAscentDescent(font, float(o['fontsize']))
ad = getAscentDescent(font, fontsize)
align_map = {
'left': TA_LEFT,
@@ -985,16 +986,17 @@ class Renderer:
# lineheight display differs from browser canvas. This calc is just empirical values to get
# reportlab render similarly to browser canvas.
# for backwards compatability use „uncorrected“ lineheight of 1.0 instead of 1.15
lineheight = float(o['lineheight']) * 1.15 if 'lineheight' in o else 1.0
lineheight = float(o['lineheight']) * 1.15 if not legacy_lineheight or 'lineheight' in o else 1.0
style = ParagraphStyle(
name=uuid.uuid4().hex,
fontName=font,
fontSize=float(o['fontsize']),
leading=lineheight * float(o['fontsize']),
fontSize=fontsize,
leading=lineheight * fontsize,
# for backwards compatability use autoLeading if no lineheight is given
autoLeading='off' if 'lineheight' in o else 'max',
autoLeading='off' if not legacy_lineheight or 'lineheight' in o else 'max',
textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255),
alignment=align_map[o['align']]
alignment=align_map[o['align']],
splitLongWords=o.get('splitlongwords', True),
)
# add an almost-invisible space &hairsp; after hyphens as word-wrap in ReportLab only works on space chars
text = conditional_escape(
@@ -1013,6 +1015,41 @@ class Renderer:
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
p = Paragraph(text, style=style)
return p, ad, lineheight
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
fontsize = float(o['fontsize'])
height = float(o['height']) * mm
width = float(o['width']) * mm
while True:
p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize)
w, h = p.wrapOn(canvas, width, 1000 * mm)
widths = p.getActualLineWidths0()
if not widths:
break
actual_w = max(widths)
if not o.get('autoresize', False) or (h <= height and actual_w <= width) or fontsize <= 1.0:
break
if h > height: # we can do larger steps for height
fontsize -= max(1.0, fontsize * .1)
else:
fontsize -= max(.25, fontsize * .025)
canvas.saveState()
# The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get
# reportlab render similarly to browser canvas.
canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + height)
canvas.rotate(o.get('rotation', 0) * -1)
if o.get('verticalalign', 'top') == 'top':
p.drawOn(canvas, 0, - h)
elif o.get('verticalalign', 'top') == 'middle':
p.drawOn(canvas, 0, (-height - h) / 2)
elif o.get('verticalalign', 'top') == 'bottom':
p.drawOn(canvas, 0, -height)
canvas.restoreState()
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
p, ad, lineheight = self._text_paragraph(op, order, o, legacy_lineheight=True)
w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm)
# p_size = p.wrap(float(o['width']) * mm, 1000 * mm)
canvas.saveState()
@@ -1051,6 +1088,8 @@ class Renderer:
self._draw_barcodearea(canvas, op, order, o)
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textcontainer":
self._draw_textcontainer(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":

View File

@@ -1154,7 +1154,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
)
@receiver(order_placed, dispatch_uid="autocheckin_order_placed")
@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed")
def order_placed(sender, **kwargs):
order = kwargs['order']
event = sender
@@ -1171,7 +1171,7 @@ def order_placed(sender, **kwargs):
checkin_created.send(event, checkin=ci)
@receiver(periodic_task, dispatch_uid="autocheckin_exit_all")
@receiver(periodic_task, dispatch_uid="autocheckout_exit_all")
@scopes_disabled()
def process_exit_all(sender, **kwargs):
qs = CheckinList.objects.filter(

View File

@@ -58,6 +58,7 @@ from django.core.mail import (
from django.core.mail.message import SafeMIMEText
from django.db import transaction
from django.template.loader import get_template
from django.utils.html import escape
from django.utils.timezone import now, override
from django.utils.translation import gettext as _, pgettext
from django_scopes import scope, scopes_disabled
@@ -109,6 +110,22 @@ def clean_sender_name(sender_name: str) -> str:
return sender_name
def prefix_subject(settings_holder, subject, highlight=False):
prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
prefix = f"[{prefix}]"
if highlight:
prefix = '<span class="placeholder" title="{}">{}</span>'.format(
_('This prefix has been set in your event or organizer settings.'),
escape(prefix)
)
subject = f"{prefix} {subject}"
return subject
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
@@ -240,11 +257,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail
prefix = settings_holder.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
subject = "[%s] %s" % (prefix, subject)
subject = prefix_subject(settings_holder, subject)
body_plain += "\r\n\r\n-- \r\n"

View File

@@ -3152,7 +3152,7 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
if order.status != Order.STATUS_PAID or not order.customer:
return
for p in order.positions.all():
if p.item.grant_membership_type_id:
if p.item.grant_membership_type_id and not p.granted_memberships.exists():
create_membership(order.customer, p)

View File

@@ -1295,7 +1295,8 @@ DEFAULTS = {
'form_kwargs': dict(
label=_("Show event times and dates on the ticket shop"),
help_text=_("If disabled, no date or time will be shown on the ticket shop's front page. This settings "
"does however not affect the display in other locations."),
"also affects a few other locations, however it should not be expected that the date of the "
"event is shown nowhere to users."),
)
},
'show_date_to': {
@@ -1480,7 +1481,7 @@ DEFAULTS = {
widget=forms.NumberInput(),
help_text=_('With an increased limit, a customer may request more than one ticket for a specific product '
'using the same, unique email address. However, regardless of this setting, they will need to '
'fill the waitlist form multiple times if they want more than one ticket, as every entry only '
'fill the waiting list form multiple times if they want more than one ticket, as every entry only '
'grants one single ticket at a time.'),
)
},
@@ -3363,7 +3364,9 @@ Your {organizer} team""")) # noqa: W291
},
'seating_allow_blocked_seats_for_channel': {
'default': [],
'type': list
'type': list,
'serializer_class': serializers.ListField,
'serializer_kwargs': lambda: dict(child=serializers.CharField()),
},
'seating_distance_within_row': {
'default': 'False',
@@ -3801,6 +3804,16 @@ def validate_event_settings(event, settings_dict):
'payment_term_last': _('The last payment date cannot be before the end of presale.')
})
if settings_dict.get('seating_allow_blocked_seats_for_channel'):
allowed_channels = set(event.organizer.sales_channels.values_list("identifier", flat=True))
for channel in settings_dict['seating_allow_blocked_seats_for_channel']:
if channel not in allowed_channels:
raise ValidationError({
'seating_allow_blocked_seats_for_channel': _('The value "{identifier}" is not a valid sales channel.').format(
identifier=channel
)
})
if isinstance(event, Event):
validate_event_settings.send(sender=event, settings_dict=settings_dict)