mirror of
https://github.com/pretix/pretix.git
synced 2025-12-27 17:42:27 +00:00
Compare commits
6 Commits
subevent-f
...
invoice-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c3a3297ca | ||
|
|
bb16c88a8d | ||
|
|
1662c65b9b | ||
|
|
dabdac6ff2 | ||
|
|
50d724f9e3 | ||
|
|
ae5c0a5537 |
@@ -80,17 +80,12 @@ lines list of objects The actual invo
|
||||
for all invoice lines
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a fee (e.g. a product).
|
||||
├ event_date_from datetime Start date of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees).
|
||||
├ event_date_to datetime End date of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
an event series not created by a product (e.g. shipping or
|
||||
cancellation fees) as well as whenever the respective (sub)event
|
||||
has no end date set.
|
||||
├ period_start datetime Start date of the service or delivery period of the invoice line.
|
||||
Can be ``null`` if not known.
|
||||
├ period_end datetime End date of the service or delivery period of the invoice line.
|
||||
Can be ``null`` if not known.
|
||||
├ event_date_from datetime Deprecated alias of ``period_start``.
|
||||
├ event_date_to datetime Deprecated alias of ``period_end``.
|
||||
├ event_location string Location of the (sub)event this line was created for as it
|
||||
was set during invoice creation. Can be ``null`` for all invoice
|
||||
lines created before this was introduced as well as for lines in
|
||||
@@ -274,6 +269,8 @@ List of all invoices
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"period_start": "2017-12-27T10:00:00Z",
|
||||
"period_end": "2017-12-27T10:00:00Z",
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
@@ -420,6 +417,8 @@ Fetching individual invoices
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
"event_date_to": null,
|
||||
"period_start": "2017-12-27T10:00:00Z",
|
||||
"period_end": "2017-12-27T10:00:00Z",
|
||||
"event_location": "Heidelberg",
|
||||
"attendee_name": null,
|
||||
"gross_value": "23.00",
|
||||
|
||||
@@ -23,7 +23,7 @@ There are multiple signals that will be sent out in the ordering cycle:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:no-index:
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
|
||||
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, build_invoice_data, invoice_line_text
|
||||
|
||||
Check-ins
|
||||
"""""""""
|
||||
@@ -37,7 +37,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head, filter_subevents
|
||||
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header, seatingframe_html_head
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
|
||||
@@ -805,6 +805,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_include_free',
|
||||
'invoice_generate',
|
||||
'invoice_period',
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
'invoice_numbers_prefix_cancellations',
|
||||
|
||||
@@ -1757,12 +1757,14 @@ class LinePositionField(serializers.IntegerField):
|
||||
|
||||
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
position = LinePositionField(read_only=True)
|
||||
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
|
||||
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type',
|
||||
'fee_internal_type', 'event_location')
|
||||
'event_date_to', 'period_start', 'period_end', 'gross_value', 'tax_value', 'tax_rate', 'tax_code',
|
||||
'tax_name', 'fee_type', 'fee_internal_type', 'event_location')
|
||||
|
||||
|
||||
class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -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,12 @@ 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
|
||||
has_multiple_service_dates = len(set(
|
||||
(il.period_start, il.period_end) for il in self.invoice.lines.all()
|
||||
)) > 1
|
||||
request_show_service_date = False
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
@@ -700,15 +713,73 @@ 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"{date_format(day(period_start), 'SHORT_DATE_FORMAT')} – {date_format(day(period_end), 'SHORT_DATE_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 = date_format(delivery_day, 'SHORT_DATE_FORMAT')
|
||||
else:
|
||||
# No period known
|
||||
period_line = ""
|
||||
|
||||
if not has_multiple_service_dates and period_line:
|
||||
# Group together at the end of the invoice
|
||||
request_show_service_date = period_line
|
||||
else:
|
||||
description += "\n" + period_line
|
||||
|
||||
lines = list(lines)
|
||||
if has_taxes:
|
||||
if len(lines) > 1:
|
||||
@@ -717,6 +788,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']),
|
||||
@@ -821,6 +893,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
|
||||
if request_show_service_date:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.payment_provider_text:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(self.invoice.payment_provider_text),
|
||||
|
||||
80
src/pretix/base/migrations/0289_invoiceline_period.py
Normal file
80
src/pretix/base/migrations/0289_invoiceline_period.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Generated by Django 4.2.17 on 2025-09-08 08:14
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_invoice_period(apps, schema_editor):
|
||||
EventSettingsStore = apps.get_model("pretixbase", "Event_SettingsStore")
|
||||
ev_seen = set()
|
||||
insert_queue = []
|
||||
flush_queue = []
|
||||
|
||||
def store():
|
||||
EventSettingsStore.objects.bulk_create(
|
||||
insert_queue,
|
||||
update_conflicts=True,
|
||||
update_fields=["value"],
|
||||
unique_fields=["object", "key"],
|
||||
)
|
||||
for f in flush_queue:
|
||||
cache.delete(f)
|
||||
flush_queue.clear()
|
||||
insert_queue.clear()
|
||||
|
||||
# Existing events that use pretix-zugferd and have explicitly disabled delivery dates
|
||||
for setting in EventSettingsStore.objects.filter(key="zugferd_include_delivery_date", value="False"):
|
||||
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
|
||||
insert_queue.append(
|
||||
EventSettingsStore(
|
||||
object_id=setting.object_id,
|
||||
key="invoice_period",
|
||||
value="invoice_date",
|
||||
)
|
||||
)
|
||||
ev_seen.add(setting.object_id)
|
||||
|
||||
if len(insert_queue) > 1000:
|
||||
store()
|
||||
|
||||
# Existing events that previously hid their date on invoices
|
||||
for setting in EventSettingsStore.objects.filter(key="show_dates_on_frontpage", value="False"):
|
||||
if setting.object_id in ev_seen:
|
||||
continue
|
||||
|
||||
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
|
||||
insert_queue.append(
|
||||
EventSettingsStore(
|
||||
object_id=setting.object_id,
|
||||
key="invoice_period",
|
||||
value="auto_no_event",
|
||||
)
|
||||
)
|
||||
ev_seen.add(setting.object_id)
|
||||
|
||||
if len(insert_queue) > 1000:
|
||||
store()
|
||||
|
||||
store()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0288_invoice_transmission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="invoiceline",
|
||||
old_name="event_date_to",
|
||||
new_name="period_end",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="invoiceline",
|
||||
old_name="event_date_from",
|
||||
new_name="period_start",
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_invoice_period,
|
||||
migrations.RunPython.noop,
|
||||
)
|
||||
]
|
||||
18
src/pretix/base/migrations/0290_invoice_plugin_data.py
Normal file
18
src/pretix/base/migrations/0290_invoice_plugin_data.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.17 on 2025-09-09 09:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0289_invoiceline_period"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="plugin_data",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import string
|
||||
import warnings
|
||||
from decimal import Decimal
|
||||
|
||||
import pycountry
|
||||
@@ -200,6 +201,7 @@ class Invoice(models.Model):
|
||||
transmission_info = models.JSONField(null=True, blank=True)
|
||||
|
||||
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
|
||||
plugin_data = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
@@ -387,10 +389,10 @@ class InvoiceLine(models.Model):
|
||||
:type tax_name: str
|
||||
:param subevent: The subevent this line refers to
|
||||
:type subevent: SubEvent
|
||||
:param event_date_from: Event date of the (sub)event at the time the invoice was created
|
||||
:type event_date_from: datetime
|
||||
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
|
||||
:type event_date_to: datetime
|
||||
:param period_start: Start if service period invoiced
|
||||
:type period_start: datetime
|
||||
:param period_end: End of service period invoiced
|
||||
:type period_end: datetime
|
||||
:param event_location: Event location of the (sub)event at the time the invoice was created
|
||||
:type event_location: str
|
||||
:param item: The item this line refers to
|
||||
@@ -409,8 +411,8 @@ class InvoiceLine(models.Model):
|
||||
tax_name = models.CharField(max_length=190)
|
||||
tax_code = models.CharField(max_length=190, null=True, blank=True)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
event_date_from = models.DateTimeField(null=True)
|
||||
event_date_to = models.DateTimeField(null=True)
|
||||
period_start = models.DateTimeField(null=True)
|
||||
period_end = models.DateTimeField(null=True)
|
||||
event_location = models.TextField(null=True, blank=True)
|
||||
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
|
||||
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
|
||||
@@ -427,3 +429,35 @@ class InvoiceLine(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return 'Line {} of invoice {}'.format(self.position, self.invoice)
|
||||
|
||||
@property
|
||||
def event_date_from(self):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
return self.period_start
|
||||
|
||||
@event_date_from.setter
|
||||
def event_date_from(self, value):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
self.period_start = value
|
||||
|
||||
@property
|
||||
def event_date_to(self):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
return self.period_end
|
||||
|
||||
@event_date_to.setter
|
||||
def event_date_to(self, value):
|
||||
warnings.warn(
|
||||
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
self.period_to = value
|
||||
|
||||
@@ -61,7 +61,9 @@ from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import (
|
||||
TransactionAwareProfiledEventTask, TransactionAwareTask,
|
||||
)
|
||||
from pretix.base.signals import invoice_line_text, periodic_task
|
||||
from pretix.base.signals import (
|
||||
build_invoice_data, invoice_line_text, periodic_task,
|
||||
)
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import OF_SELF, rolledback_transaction
|
||||
from pretix.helpers.models import modelcopy
|
||||
@@ -82,6 +84,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
lp = invoice.order.payments.last()
|
||||
|
||||
min_period_start = None
|
||||
max_period_end = None
|
||||
now_dt = now()
|
||||
|
||||
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 +214,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 +275,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
location=_location_oneliner(location)
|
||||
)
|
||||
|
||||
period_start, period_end = _service_period_for_position(invoice, p, now_dt)
|
||||
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 +289,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,
|
||||
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
|
||||
event_date_to=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 +313,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,
|
||||
event_date_from=None if invoice.event.has_subevents else invoice.event.date_from,
|
||||
event_date_to=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)
|
||||
@@ -336,6 +364,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.reverse_charge = reverse_charge
|
||||
invoice.save()
|
||||
|
||||
build_invoice_data.send(sender=invoice.event, invoice=invoice)
|
||||
return invoice
|
||||
|
||||
|
||||
@@ -351,6 +380,49 @@ def build_cancellation(invoice: Invoice):
|
||||
return invoice
|
||||
|
||||
|
||||
def _service_period_for_position(invoice, position, invoice_dt):
|
||||
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:
|
||||
if position.subevent:
|
||||
period_start = position.subevent.date_from
|
||||
period_end = position.subevent.date_to
|
||||
else:
|
||||
# Currently impossible case, but might not be in the future and never makes
|
||||
# sense to use the event date here
|
||||
period_start = invoice_dt
|
||||
period_end = invoice_dt
|
||||
elif invoice.event.settings.invoice_period == "auto_no_event":
|
||||
period_start = invoice_dt
|
||||
period_end = invoice_dt
|
||||
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 = invoice_dt
|
||||
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 +528,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 +584,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,
|
||||
event_date_from=event.date_from,
|
||||
event_date_to=event.date_to,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
event_location=event.settings.invoice_event_location,
|
||||
)
|
||||
else:
|
||||
@@ -515,8 +593,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,
|
||||
event_date_from=event.date_from,
|
||||
event_date_to=event.date_to,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
event_location=event.settings.invoice_event_location,
|
||||
)
|
||||
|
||||
|
||||
@@ -1098,6 +1098,35 @@ DEFAULTS = {
|
||||
help_text=_("Invoices will never be automatically generated for free orders.")
|
||||
)
|
||||
},
|
||||
'invoice_period': {
|
||||
'default': 'auto',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': dict(
|
||||
choices=(
|
||||
('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')),
|
||||
),
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Date of service"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('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': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
|
||||
@@ -596,6 +596,18 @@ multiple events. Receivers should return a subclass of pretix.base.exporter.Base
|
||||
The ``sender`` keyword argument will contain an organizer.
|
||||
"""
|
||||
|
||||
build_invoice_data = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``invoice``
|
||||
|
||||
This signal is sent out every time an invoice is built, after the invoice model was created
|
||||
and filled and before the PDF generation task is started. You can use this to make changes
|
||||
to the invoice, but we recommend to mostly use it to add content to ``Invoice.plugin_data``.
|
||||
You are responsible for saving any changes to the database.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
validate_order = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``payments``, ``positions``, ``email``, ``locale``, ``invoice_address``,
|
||||
|
||||
@@ -857,6 +857,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
'invoice_show_payments',
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_generate',
|
||||
'invoice_period',
|
||||
'invoice_attendee_name',
|
||||
'invoice_event_location',
|
||||
'invoice_include_expire_date',
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||
{% bootstrap_field form.invoice_email_organizer layout="control" %}
|
||||
{% bootstrap_field form.invoice_language layout="control" %}
|
||||
{% bootstrap_field form.invoice_period layout="control" %}
|
||||
|
||||
{% if not request.event.settings.show_dates_on_frontpage %}
|
||||
<div data-display-dependency="input[name=invoice_period][value=auto],input[name=invoice_period][value=event_date]">
|
||||
<div class="alert alert-warning dynamic">
|
||||
{% blocktrans trimmed %}
|
||||
You configured that your shop is not an event and the event date should not be shown.
|
||||
Therefore, we recommend that you set the date of service to a different option.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% bootstrap_field form.invoice_include_free layout="control" %}
|
||||
{% bootstrap_field form.invoice_show_payments layout="control" %}
|
||||
{% bootstrap_field form.invoice_reissue_after_modify layout="control" %}
|
||||
|
||||
@@ -415,19 +415,3 @@ consent state. Receivers should return a list of ``pretix.presale.cookies.Cookie
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
filter_subevents = GlobalSignal()
|
||||
"""
|
||||
Arguments: ``subevents``, ``sales_channel``
|
||||
|
||||
This signal allows you to filter which subevents are publicly available. Receivers are passed a
|
||||
list of subevents that are about to be shown to the user and are expected to return a list of the
|
||||
same format, with all subevents removed that should not be available for sale.
|
||||
|
||||
``sales_channels`` is a ``SalesChannel`` instance.
|
||||
|
||||
This is not an event-plugin signal as this will also be called on the organizer level when showing
|
||||
a list of subevents across events. Expect that the subevents in the input are mixed from different
|
||||
events. However, receivers will only receive subevents of events that the plugin is active for and
|
||||
can only filter out these.
|
||||
"""
|
||||
|
||||
@@ -85,8 +85,7 @@ from pretix.presale.ical import get_public_ical
|
||||
from pretix.presale.signals import item_description, seatingframe_html_head
|
||||
from pretix.presale.views.organizer import (
|
||||
EventListMixin, add_subevents_for_days, days_for_template,
|
||||
filter_qs_by_attr, filter_subevents_with_plugins, has_before_after,
|
||||
weeks_for_template,
|
||||
filter_qs_by_attr, has_before_after, weeks_for_template,
|
||||
)
|
||||
|
||||
from . import (
|
||||
@@ -547,12 +546,6 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
self.subevent = request.event.subevents.using(settings.DATABASE_REPLICA).filter(pk=kwargs['subevent'], active=True).first()
|
||||
if not self.subevent:
|
||||
raise Http404()
|
||||
|
||||
# Prevent direct access to subevents that are hidden by a plugin
|
||||
subevents = filter_subevents_with_plugins([self.subevent], self.request.sales_channel)
|
||||
if self.subevent not in subevents:
|
||||
raise Http404()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
else:
|
||||
return super().get(request, *args, **kwargs)
|
||||
@@ -710,14 +703,9 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
).using(settings.DATABASE_REPLICA),
|
||||
self.request
|
||||
),
|
||||
before=limit_before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=set(),
|
||||
event=self.request.event,
|
||||
cart_namespace=self.kwargs.get('cart_namespace'),
|
||||
voucher=voucher,
|
||||
sales_channel=self.request.sales_channel,
|
||||
limit_before, after, ebd, set(), self.request.event,
|
||||
self.kwargs.get('cart_namespace'),
|
||||
voucher,
|
||||
)
|
||||
|
||||
# Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times
|
||||
@@ -774,14 +762,9 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
).using(settings.DATABASE_REPLICA),
|
||||
self.request
|
||||
),
|
||||
before=limit_before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=set(),
|
||||
event=self.request.event,
|
||||
cart_namespace=self.kwargs.get('cart_namespace'),
|
||||
voucher=voucher,
|
||||
sales_channel=self.request.sales_channel,
|
||||
limit_before, after, ebd, set(), self.request.event,
|
||||
self.kwargs.get('cart_namespace'),
|
||||
voucher,
|
||||
)
|
||||
|
||||
# Hide names of subevents in event series where it is always the same. No need to show the name of the museum thousands of times
|
||||
@@ -820,7 +803,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
future_only=self.request.event.settings.event_calendar_future_only
|
||||
)
|
||||
else:
|
||||
subevents = self.request.event.subevents_sorted(
|
||||
context['subevent_list'] = self.request.event.subevents_sorted(
|
||||
filter_qs_by_attr(
|
||||
self.request.event.subevents_annotated(
|
||||
self.request.sales_channel,
|
||||
@@ -829,14 +812,12 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
self.request
|
||||
)
|
||||
)
|
||||
subevents = filter_subevents_with_plugins(list(subevents), self.request.sales_channel)
|
||||
context['subevent_list'] = subevents
|
||||
if self.request.event.settings.event_list_available_only and not voucher:
|
||||
context['subevent_list'] = [
|
||||
se for se in subevents
|
||||
se for se in context['subevent_list']
|
||||
if not se.presale_has_ended and (se.best_availability_state is None or se.best_availability_state >= Quota.AVAILABILITY_RESERVED)
|
||||
]
|
||||
context['visible_events'] = len(subevents) > 0
|
||||
context['visible_events'] = len(context['subevent_list']) > 0
|
||||
return context
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ from django.db.models import (
|
||||
Case, Exists, F, Max, Min, OuterRef, Prefetch, Q, Value, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, Greatest
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
from django.http import Http404, HttpResponse, QueryDict
|
||||
from django.templatetags.static import static
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -77,7 +76,6 @@ from pretix.helpers.thumb import get_thumbnail
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.presale.forms.organizer import EventListFilterForm
|
||||
from pretix.presale.ical import get_public_ical
|
||||
from pretix.presale.signals import filter_subevents
|
||||
from pretix.presale.views import OrganizerViewMixin
|
||||
|
||||
|
||||
@@ -563,56 +561,16 @@ def add_events_for_days(request, baseqs, before, after, ebd, timezones):
|
||||
})
|
||||
|
||||
|
||||
def filter_subevents_with_plugins(subevents, sales_channel=None):
|
||||
# Special-case of GlobalSignal.send_chained() that only sends subevents that have the plugin enabled
|
||||
# and then mixes the results back together.
|
||||
from pretix.base.signals import (
|
||||
_populate_app_cache, app_cache, get_defining_app, is_app_active,
|
||||
)
|
||||
|
||||
if not filter_subevents.receivers or filter_subevents.sender_receivers_cache.get(None) is NO_RECEIVERS:
|
||||
return subevents
|
||||
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in filter_subevents._live_receivers(None):
|
||||
app = get_defining_app(receiver)
|
||||
event_state = {}
|
||||
|
||||
def app_active(event):
|
||||
if event.pk not in event_state:
|
||||
event_state[event.pk] = is_app_active(event, app, allow_legacy_plugins=True)
|
||||
return event_state[event.pk]
|
||||
|
||||
subevents_passed_to_receiver = [
|
||||
s for s in subevents if app_active(s.event)
|
||||
]
|
||||
response = receiver(
|
||||
signal=filter_subevents,
|
||||
sender=None,
|
||||
subevents=subevents_passed_to_receiver,
|
||||
sales_channel=sales_channel,
|
||||
)
|
||||
subevents = [
|
||||
s for s in subevents if s in response or s not in subevents_passed_to_receiver
|
||||
]
|
||||
|
||||
return subevents
|
||||
|
||||
|
||||
def add_subevents_for_days(qs, before, after, ebd, timezones, sales_channel, event=None, cart_namespace=None,
|
||||
voucher=None):
|
||||
def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_namespace=None, voucher=None):
|
||||
qs = qs.filter(active=True, is_public=True).filter(
|
||||
Q(Q(date_to__gte=before) & Q(date_from__lte=after)) |
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after))
|
||||
).order_by(
|
||||
'date_from'
|
||||
)
|
||||
subevents = filter_subevents_with_plugins(list(qs), sales_channel)
|
||||
|
||||
quotas_to_compute = []
|
||||
for se in subevents:
|
||||
for se in qs:
|
||||
if se.presale_is_running:
|
||||
quotas_to_compute += se.active_quotas
|
||||
for q in se.active_quotas:
|
||||
@@ -630,7 +588,7 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, sales_channel, eve
|
||||
qa.compute(allow_cache=True)
|
||||
qcache.update(qa.results)
|
||||
|
||||
for se in subevents:
|
||||
for se in qs:
|
||||
if qcache:
|
||||
se._quota_cache = qcache
|
||||
if event is not None: # save database lookup later
|
||||
@@ -805,31 +763,24 @@ class CalendarView(OrganizerViewMixin, EventListMixin, TemplateView):
|
||||
).filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel),
|
||||
), before, after, ebd, timezones)
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA),
|
||||
before=before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
sales_channel=self.request.sales_channel,
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones)
|
||||
self._multiple_timezones = len(timezones) > 1
|
||||
return ebd
|
||||
|
||||
@@ -909,31 +860,24 @@ class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView):
|
||||
).filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel),
|
||||
), before, after, ebd, timezones)
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA),
|
||||
before=before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
sales_channel=self.request.sales_channel,
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones)
|
||||
self._multiple_timezones = len(timezones) > 1
|
||||
return ebd
|
||||
|
||||
@@ -1267,31 +1211,24 @@ class DayCalendarView(OrganizerViewMixin, EventListMixin, TemplateView):
|
||||
).filter(
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel),
|
||||
), before, after, ebd, timezones)
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
queryset=Event.objects.prefetch_related(
|
||||
'_settings_objects',
|
||||
Prefetch(
|
||||
'organizer',
|
||||
queryset=Organizer.objects.prefetch_related('_settings_objects')
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA),
|
||||
before=before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
sales_channel=self.request.sales_channel,
|
||||
)
|
||||
)
|
||||
), self.request.sales_channel), self.request).using(settings.DATABASE_REPLICA), before, after, ebd, timezones)
|
||||
self._multiple_timezones = len(timezones) > 1
|
||||
return ebd
|
||||
|
||||
|
||||
@@ -73,8 +73,7 @@ from pretix.presale.views.event import (
|
||||
)
|
||||
from pretix.presale.views.organizer import (
|
||||
EventListMixin, add_events_for_days, add_subevents_for_days,
|
||||
days_for_template, filter_qs_by_attr, filter_subevents_with_plugins,
|
||||
weeks_for_template,
|
||||
days_for_template, filter_qs_by_attr, weeks_for_template,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -404,14 +403,6 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
return self.response({
|
||||
'error': gettext('The selected date does not exist in this event series.')
|
||||
})
|
||||
|
||||
# Prevent direct access to subevents that are hidden by a plugin
|
||||
subevents = filter_subevents_with_plugins([self.subevent], request.sales_channel)
|
||||
if self.subevent not in subevents:
|
||||
return self.response({
|
||||
'error': gettext('The selected date is not available.')
|
||||
})
|
||||
|
||||
else:
|
||||
return self._get_event_list(request, **kwargs)
|
||||
else:
|
||||
@@ -577,9 +568,8 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||||
), self.request
|
||||
),
|
||||
before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event,
|
||||
cart_namespace=kwargs.get('cart_namespace'),
|
||||
sales_channel=self.request.sales_channel,
|
||||
limit_before, after, ebd, set(), self.request.event,
|
||||
kwargs.get('cart_namespace')
|
||||
)
|
||||
else:
|
||||
timezones = set()
|
||||
@@ -590,27 +580,17 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel),
|
||||
), self.request
|
||||
),
|
||||
before=limit_before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
)
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels__identifier=self.request.sales_channel.identifier),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
'event___settings_objects', 'event__organizer___settings_objects'
|
||||
), self.request.sales_channel), self.request),
|
||||
before=limit_before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
sales_channel=self.request.sales_channel,
|
||||
limit_before, after, ebd, timezones
|
||||
)
|
||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
Q(event__all_sales_channels=True) |
|
||||
Q(event__limit_sales_channels__identifier=self.request.sales_channel.identifier),
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
'event___settings_objects', 'event__organizer___settings_objects'
|
||||
), self.request.sales_channel), self.request), limit_before, after, ebd, timezones)
|
||||
|
||||
data['weeks'] = weeks_for_template(ebd, self.year, self.month)
|
||||
for w in data['weeks']:
|
||||
@@ -644,9 +624,8 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
if hasattr(self.request, 'event'):
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel), self.request),
|
||||
before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event,
|
||||
cart_namespace=kwargs.get('cart_namespace'),
|
||||
sales_channel=self.request.sales_channel,
|
||||
limit_before, after, ebd, set(), self.request.event,
|
||||
kwargs.get('cart_namespace')
|
||||
)
|
||||
else:
|
||||
timezones = set()
|
||||
@@ -655,20 +634,13 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
filter_qs_by_attr(Event.annotated(self.request.organizer.events, self.request.sales_channel), self.request),
|
||||
limit_before, after, ebd, timezones
|
||||
)
|
||||
add_subevents_for_days(
|
||||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
'event___settings_objects', 'event__organizer___settings_objects'
|
||||
), self.request.sales_channel), self.request),
|
||||
before=limit_before,
|
||||
after=after,
|
||||
ebd=ebd,
|
||||
timezones=timezones,
|
||||
sales_channel=self.request.sales_channel,
|
||||
)
|
||||
add_subevents_for_days(filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
event__is_public=True,
|
||||
event__live=True,
|
||||
).prefetch_related(
|
||||
'event___settings_objects', 'event__organizer___settings_objects'
|
||||
), self.request.sales_channel), self.request), limit_before, after, ebd, timezones)
|
||||
|
||||
data['days'] = days_for_template(ebd, week)
|
||||
for d in data['days']:
|
||||
|
||||
@@ -231,7 +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': '2017-12-27T10:00:00Z',
|
||||
'event_location': None,
|
||||
'attendee_name': 'Peter',
|
||||
'item': None,
|
||||
@@ -249,7 +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': '2017-12-27T10:00:00Z',
|
||||
'event_location': None,
|
||||
'attendee_name': None,
|
||||
'fee_type': "payment",
|
||||
|
||||
@@ -608,7 +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': '2017-12-27T10:00:00Z',
|
||||
'event_location': None,
|
||||
'fee_type': None,
|
||||
'fee_internal_type': None,
|
||||
@@ -626,7 +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': '2017-12-27T10:00:00Z',
|
||||
'event_location': None,
|
||||
'fee_type': "payment",
|
||||
'fee_internal_type': None,
|
||||
|
||||
@@ -33,7 +33,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 +42,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 +63,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 +662,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
|
||||
|
||||
Reference in New Issue
Block a user