Fix tests

This commit is contained in:
Raphael Michel
2025-09-08 18:15:40 +02:00
parent ae5c0a5537
commit 50d724f9e3
6 changed files with 331 additions and 45 deletions

View File

@@ -19,6 +19,7 @@
# 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 datetime
import logging
import re
import unicodedata
@@ -522,6 +523,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event'))))
canvas.drawText(textobject)
def _date_range_in_header(self):
if self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
return None, None
tz = self.invoice.event.timezone
show_end_date = (
self.invoice.event.settings.show_date_to and
self.invoice.event.date_to and
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
)
if show_end_date:
return self.invoice.event.date_from.astimezone(tz).date(), self.invoice.event.date_to.astimezone(tz).date
else:
return self.invoice.event.date_from.astimezone(tz).date(), None
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
@@ -535,25 +550,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_size = p.wrap(self.event_width, self.event_height)
return txt
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
tz = self.invoice.event.timezone
show_end_date = (
self.invoice.event.settings.show_date_to and
self.invoice.event.date_to and
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
d_from, d_to = self._date_range_in_header()
if d_from and d_to:
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=date_format(d_from, "DATE_FORMAT"),
to_date=date_format(d_to, "DATE_FORMAT"),
)
)
if show_end_date:
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(show_times=False),
to_date=self.invoice.event.get_date_to_display(show_times=False)
)
)
else:
p_str = (
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
)
elif d_from:
p_str = shorten(self.invoice.event.name) + '\n' + date_format(d_from, "DATE_FORMAT")
else:
p_str = shorten(self.invoice.event.name)
@@ -657,6 +664,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
header_dates = self._date_range_in_header()
tz = self.invoice.event.timezone
story = [
NextPageTemplate('FirstPage'),
@@ -700,15 +709,68 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)]
def _group_key(line):
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
line.event_date_from, line.event_date_to)
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent,
line.period_start, line.period_end)
def day(dt: datetime.datetime) -> datetime.date:
if dt is None:
return None
return dt.astimezone(tz).date()
total = Decimal('0.00')
for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby(
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
self.invoice.lines.all(),
key=_group_key,
is_addon=lambda l: l.description.startswith(" +"),
):
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
# (b) due to not respecting the show_date_to setting of events in cases where we could have respected it.
# Still, we want to show the date explicitly if its different to the event or invoice date.
if period_start and period_end and day(period_end) != day(period_start):
# It's a multi-day period, such as the validity of the ticket or an event date period
if day(period_start) == header_dates[0] and day(period_end) == header_dates[1]:
# This is the exact event period we already printed in the header, no need to repeat it.
period_line = ""
elif (self.event.has_subevents and subevent and day(subevent.date_from) == day(period_start) and
day(subevent.date_to) == day(period_end)):
# For subevents, build_invoice already includes the date in the description in the event-default format.
period_line = ""
else:
period_line = f"\n{date_format(day(period_start), 'SHORT_DATE_FORMAT')} {date_format(day(period_end), 'SHORT_END_FORMAT')}"
elif period_start or period_end:
# It's a single-day period
delivery_day = day(period_end or period_start)
if delivery_day in (header_dates[0], header_dates[1]):
# This is the event date we already printed in the header, no need to repeat it.
period_line = ""
elif self.event.has_subevents and subevent and delivery_day in (day(subevent.date_from), day(subevent.date_to)):
# For subevents, build_invoice already includes the date in the description in the event-default format.
period_line = ""
elif (delivery_day == self.invoice.date) and header_dates[0] is None:
# This is a shop that doesn't show the date of the event in the header, and the period is the invoice
# date. We assume that this is an 'everything is executed immediately' situation and do not want to
# confuse with showing additional dates on the invoice. This is the case that is not guaranteed to be
# correct in all cases and might need to change in the future. If customers have legal concerns, a
# quick fix is including a sentence like "Delivery date is the invoice date unless otherwise indicated:"
# in a custom text on the invoice.
period_line = ""
else:
period_line = f"\n{date_format(day(delivery_day), 'SHORT_DATE_FORMAT')}"
else:
# No period known
period_line = ""
description += period_line
lines = list(lines)
if has_taxes:
if len(lines) > 1:
@@ -717,6 +779,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
FontFallbackParagraph(
self._clean_text(description, tags=['br']),

View File

@@ -82,6 +82,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
lp = invoice.order.payments.last()
min_period_start = None
max_period_end = None
with (language(invoice.locale, invoice.event.settings.region)):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -208,7 +211,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
).prefetch_related('answers', 'answers__options', 'answers__question').order_by('positionid', 'id')
).prefetch_related(
'answers', 'answers__options', 'answers__question', 'granted_memberships',
).order_by('positionid', 'id')
)
reverse_charge = False
@@ -267,6 +272,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
location=_location_oneliner(location)
)
period_start, period_end = _service_period_for_position(invoice, p)
min_period_start = min(min_period_start or period_start, period_start)
max_period_end = min(max_period_end or period_end, period_end)
InvoiceLine.objects.create(
position=i,
invoice=invoice,
@@ -277,8 +286,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
item=p.item,
variation=p.variation,
attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None,
period_start=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
period_end=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
period_start=period_start,
period_end=period_end,
event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate,
tax_code=p.tax_code,
@@ -301,13 +310,29 @@ def build_invoice(invoice: Invoice) -> Invoice:
fee_title = _(fee.get_fee_type_display())
if fee.description:
fee_title += " - " + fee.description
if min_period_start and max_period_end:
# Consider fees to have the same service period as the products sold
period_start = min_period_start
period_end = max_period_end
else:
# Usually can only happen if everything except a cancellation fee is removed
if invoice.event.settings.invoice_period in ("auto", "auto_no_event", "event_date") and not invoice.event.has_subevents:
# Non-series event, let's be backwards-compatible and tag everything with the event period
period_start = invoice.event.date_from
period_end = invoice.event.date_to
else:
# We could try to work from the canceled positions, but it doesn't really make sense. A cancellation
# fee is not "delivered" at the event date, it is rather effective right now.
period_start = period_end = now()
InvoiceLine.objects.create(
position=i + offset,
invoice=invoice,
description=fee_title,
gross_value=fee.value,
period_start=None if invoice.event.has_subevents else invoice.event.date_from,
period_end=None if invoice.event.has_subevents else invoice.event.date_to,
period_start=period_start,
period_end=period_end,
event_location=(
None if invoice.event.has_subevents
else (str(invoice.event.location)
@@ -351,6 +376,43 @@ def build_cancellation(invoice: Invoice):
return invoice
def _service_period_for_position(invoice, position):
if invoice.event.settings.invoice_period in ("auto", "auto_no_event"):
if position.valid_from or position.valid_until:
period_start = position.valid_from or now()
period_end = position.valid_until
elif memberships := list(position.granted_memberships.all()):
period_start = min(m.date_start for m in memberships)
period_end = max(m.date_end for m in memberships)
elif invoice.event.has_subevents and position.subevent:
period_start = position.subevent.date_from
period_end = position.subevent.date_to
elif invoice.event.settings.invoice_period == "auto_no_event":
period_start = now()
period_end = now()
else:
period_start = invoice.event.date_from
period_end = invoice.event.date_to
elif invoice.event.settings.invoice_period == "order_date":
period_start = invoice.order.datetime
period_end = invoice.order.datetime
elif invoice.event.settings.invoice_period == "event_date":
if position.subevent:
period_start = position.subevent.date_from
period_end = position.subevent.date_to
else:
period_start = invoice.event.date_from
period_end = invoice.event.date_to
elif invoice.event.settings.invoice_period == "invoice_date":
period_start = period_end = now()
else:
raise ValueError(f"Invalid invoice period setting '{invoice.event.settings.invoice_period}'")
if not period_end:
period_end = period_start
return period_start, period_end
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
if invoice.canceled:
raise ValueError("Invoice should not be canceled twice.")
@@ -456,6 +518,12 @@ def build_preview_invoice_pdf(event):
if not locale or locale == '__user__':
locale = event.settings.locale
if event.settings.invoice_period in ("auto", "auto_no_event", "event_date"):
period_start = event.date_from
period_end = event.date_to or event.date_from
else:
period_start = period_end = timezone.now()
with rolledback_transaction(), language(locale, event.settings.region):
order = event.orders.create(
status=Order.STATUS_PENDING, datetime=timezone.now(),
@@ -506,8 +574,8 @@ def build_preview_invoice_pdf(event):
invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code,
period_start=event.date_from,
period_end=event.date_to,
period_start=period_start,
period_end=period_end,
event_location=event.settings.invoice_event_location,
)
else:
@@ -515,8 +583,8 @@ def build_preview_invoice_pdf(event):
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0, tax_code=None,
period_start=event.date_from,
period_end=event.date_to,
period_start=period_start,
period_end=period_end,
event_location=event.settings.invoice_event_location,
)

View File

@@ -1105,8 +1105,9 @@ DEFAULTS = {
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=(
('auto', _('Ticket-specific validity or event series date or event date')),
('auto_no_event', _('Ticket-specific validity or event series date or invoice date')),
('auto', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
('auto_no_event', _('Automatic, but prefer invoice date over event date')),
('event_date', _('Event date')),
('order_date', _('Order date')),
('invoice_date', _('Invoice date')),
),
@@ -1115,13 +1116,15 @@ DEFAULTS = {
label=_("Date of service"),
widget=forms.RadioSelect,
choices=(
('auto', _('Ticket-specific validity or event series date or event date')),
('auto_no_event', _('Ticket-specific validity or event series date or invoice date')),
('auto', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
('auto_no_event', _('Automatic, but prefer invoice date over event date')),
('event_date', _('Event date')),
('order_date', _('Order date')),
('invoice_date', _('Invoice date')),
),
help_text=_("This controls what dates are shown on the invoice, but is especially important for "
"electronic invoicing."),
required=True,
)
},
'invoice_reissue_after_modify': {

View File

@@ -231,9 +231,9 @@ TEST_INVOICE_RES = {
"description": "Budget Ticket<br />Attendee: Peter",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': None,
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'attendee_name': 'Peter',
'item': None,
@@ -251,9 +251,9 @@ TEST_INVOICE_RES = {
"description": "Payment fee",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': None,
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'attendee_name': None,
'fee_type': "payment",

View File

@@ -608,9 +608,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Budget Ticket<br />Attendee: Peter',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': None,
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': None,
'fee_internal_type': None,
@@ -628,9 +628,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Payment fee',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': None,
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': "payment",
'fee_internal_type': None,

View File

@@ -19,7 +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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
@@ -33,7 +32,7 @@
# License for the specific language governing permissions and limitations under the License.
import json
from datetime import date, timedelta
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
import pytest
@@ -42,6 +41,7 @@ from django.utils.itercompat import is_iterable
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.invoice import addon_aware_groupby
from pretix.base.models import (
@@ -62,7 +62,8 @@ def env():
with scope(organizer=o):
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer'
date_from=datetime(2024, 12, 1, 9, 0, 0, tzinfo=timezone.utc),
plugins='pretix.plugins.banktransfer'
)
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
@@ -660,3 +661,154 @@ def test_addon_aware_groupby():
[True, 102, 3.00],
]],
]
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "event_date"])
def test_period_from_event_start(env, period):
event, order = env
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_from
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "event_date"])
def test_period_from_event_range(env, period):
event, order = env
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_to
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_ticket_validity(env, period):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == p1.valid_from
assert l1.period_end == p1.valid_until
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_subevent(env, period):
event, order = env
event.has_subevents = True
event.save()
se1 = event.subevents.create(
name=event.name,
active=True,
date_from=datetime((now().year + 1), 7, 31, 9, 0, 0, tzinfo=timezone.utc),
date_to=datetime((now().year + 1), 7, 31, 17, 0, 0, tzinfo=timezone.utc),
)
p1 = order.positions.first()
p1.subevent = se1
p1.save()
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == se1.date_from
assert l1.period_end == se1.date_to
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_memberships(env, period):
event, order = env
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
p1 = order.positions.first()
membershiptype = event.organizer.membership_types.create(
name=LazyI18nString({"en": "Week pass"}),
transferable=True,
allow_parallel_usage=False,
max_usages=15,
)
customer = event.organizer.customers.create(
identifier="8WSAJCJ",
email="foo@example.org",
name_parts={"_legacy": "Foo"},
name_cached="Foo",
is_verified=False,
)
m = customer.memberships.create(
membership_type=membershiptype,
granted_in=p1,
date_start=datetime(2021, 4, 1, 0, 0, 0, 0, tzinfo=timezone.utc),
date_end=datetime(2021, 4, 8, 23, 59, 59, 999999, tzinfo=timezone.utc),
attendee_name_parts={
"_scheme": "given_family",
'given_name': 'John',
'family_name': 'Doe',
}
)
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == m.date_start
assert l1.period_end == m.date_end
@pytest.mark.django_db
def test_period_auto_no_event_from_invoice(env):
event, order = env
event.settings.invoice_period = "auto_no_event"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert abs(l1.period_start - now()) < timedelta(seconds=10)
assert abs(l1.period_end - now()) < timedelta(seconds=10)
@pytest.mark.django_db
def test_period_always_invoice_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "invoice_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert abs(l1.period_start - now()) < timedelta(seconds=10)
assert abs(l1.period_end - now()) < timedelta(seconds=10)
@pytest.mark.django_db
def test_period_always_event_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "event_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_from
@pytest.mark.django_db
def test_period_always_order_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "order_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == order.datetime
assert l1.period_end == order.datetime