Add settings page

This commit is contained in:
Raphael Michel
2025-08-13 09:58:51 +02:00
parent 5adbf5bb3d
commit 0cc2155aa5
9 changed files with 227 additions and 101 deletions
+9 -4
View File
@@ -78,9 +78,9 @@ from pretix.control.forms import (
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = ( ROUNDING_MODES = (
('line', _('Rounding every line individually')), ('line', _('Round taxes for every line individually')),
('sum_by_net', _('Rounding by order total, keeping net prices stable')), ('sum_by_net', _('Round taxes by order total, keeping net prices stable')),
('sum_by_gross', _('Rounding by order total, keeping gross prices stable')), ('sum_by_gross', _('Round taxes by order total, keeping gross prices stable')),
) )
@@ -330,7 +330,7 @@ DEFAULTS = {
'form_class': forms.BooleanField, 'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Show net prices instead of gross prices in the product list (not recommended!)"), label=_("Show net prices instead of gross prices in the product list"),
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
"paid."), "paid."),
@@ -480,6 +480,11 @@ DEFAULTS = {
label=_("Rounding of taxes"), label=_("Rounding of taxes"),
widget=forms.RadioSelect, widget=forms.RadioSelect,
choices=ROUNDING_MODES, choices=ROUNDING_MODES,
help_text=_(
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
"need to make sure to account for possible rounding differences if your external system rounds "
"differently than pretix."
)
), ),
'serializer_kwargs': dict( 'serializer_kwargs': dict(
choices=ROUNDING_MODES, choices=ROUNDING_MODES,
+75 -4
View File
@@ -68,7 +68,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings, PERSON_NAME_TITLE_GROUPS, ROUNDING_MODES, validate_event_settings,
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
@@ -541,7 +541,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'show_date_to', 'show_date_to',
'show_times', 'show_times',
'show_items_outside_presale_period', 'show_items_outside_presale_period',
'display_net_prices',
'hide_prices_from_attendees', 'hide_prices_from_attendees',
'presale_start_show_date', 'presale_start_show_date',
'locales', 'locales',
@@ -799,6 +798,76 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
return value return value
class DisplayNetPricesBooleanSelect(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
("false", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices including tax"),
_("Recommended if you sell tickets at least partly to consumers.")
)),
("true", format_html(
'{} <br><span class="text-muted">{}</span>',
_("Prices excluding tax"),
_("Recommended only if you sell tickets primarily to business customers.")
)),
)
super().__init__(attrs, choices)
def format_value(self, value):
try:
return {
True: "true",
False: "false",
"true": "true",
"false": "false",
}[value]
except KeyError:
return "unknown"
def value_from_datadict(self, data, files, name):
value = data.get(name)
return {
True: True,
"True": True,
"False": False,
False: False,
"true": True,
"false": False,
}.get(value)
class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'display_net_prices',
'tax_rounding',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["display_net_prices"].label = _("Prices shown to customer")
self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect()
help_text = {
"line": _("Recommended when e-invoicing is not required. Each product will be sold with the advertised "
"net and gross price. However, in orders of more than one product, the total tax amount "
"can differ from when it would be computed from the order total."),
"sum_by_net": _("Recommended for e-invoicing in Europe when you primarily sell to business customers and "
"show prices to customers excluding tax. "
"For orders of more than one product, the gross price of some products may be changed "
"automatically to ensure correct rounding of the order total, while the net prices "
"stay as configured. This may cause the actual payment amount to differ from buying the "
"products individually."),
"sum_by_gross": _("Recommended for e-invoicing in Europe when you primarily sell to consumers. "
"For an order of more than one product, the net price of some products may be changed "
"automatically to ensure correct rounding of the order total, while the gross prices "
"stay as configured."),
}
self.fields["tax_rounding"].choices = (
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
for k, v in ROUNDING_MODES
)
class ProviderForm(SettingsForm): class ProviderForm(SettingsForm):
""" """
This is a SettingsForm, but if fields are set to required=True, validation This is a SettingsForm, but if fields are set to required=True, validation
@@ -881,7 +950,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_logo_image', 'invoice_logo_image',
'invoice_renderer_highlight_order_code', 'invoice_renderer_highlight_order_code',
'invoice_renderer_font', 'invoice_renderer_font',
'tax_rounding',
] ]
invoice_generate_sales_channels = forms.MultipleChoiceField( invoice_generate_sales_channels = forms.MultipleChoiceField(
@@ -1511,7 +1579,10 @@ class TaxRuleLineForm(I18nForm):
rate = forms.DecimalField( rate = forms.DecimalField(
label=_('Deviating tax rate'), label=_('Deviating tax rate'),
max_digits=10, decimal_places=2, max_digits=10, decimal_places=2,
required=False required=False,
widget=forms.NumberInput(attrs={
'placeholder': _('Deviating tax rate'),
})
) )
invoice_text = I18nFormField( invoice_text = I18nFormField(
label=_('Text on invoice'), label=_('Text on invoice'),
+1 -1
View File
@@ -86,7 +86,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.mail', 'active': url.url_name == 'event.settings.mail',
}, },
{ {
'label': _('Tax rules'), 'label': _('Taxes'),
'url': reverse('control:event.settings.tax', kwargs={ 'url': reverse('control:event.settings.tax', kwargs={
'event': request.event.slug, 'event': request.event.slug,
'organizer': request.event.organizer.slug, 'organizer': request.event.organizer.slug,
@@ -21,7 +21,6 @@
{% bootstrap_field form.invoice_numbers_prefix layout="control" %} {% bootstrap_field form.invoice_numbers_prefix layout="control" %}
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %} {% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
{% bootstrap_field form.invoice_numbers_counter_length layout="control" %} {% bootstrap_field form.invoice_numbers_counter_length layout="control" %}
{% bootstrap_field form.tax_rounding layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Address form" %}</legend> <legend>{% trans "Address form" %}</legend>
@@ -243,7 +243,6 @@
{% bootstrap_field sform.show_times layout="control" %} {% bootstrap_field sform.show_times layout="control" %}
<h4>{% trans "Product list" %}</h4> <h4>{% trans "Product list" %}</h4>
{% bootstrap_field sform.show_quota_left layout="control" %} {% bootstrap_field sform.show_quota_left layout="control" %}
{% bootstrap_field sform.display_net_prices layout="control" %}
{% bootstrap_field sform.show_variations_expanded layout="control" %} {% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %} {% bootstrap_field sform.hide_sold_out layout="control" %}
@@ -0,0 +1,120 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Taxes" %}{% endblock %}
{% block inside %}
<h1>{% trans "Taxes" %}</h1>
{% bootstrap_form_errors form layout="control" %}
<fieldset>
<legend>{% trans "Tax rules" %}</legend>
<p>
{% blocktrans trimmed %}
Tax rules define different taxation scenarios that can then be assigned to the individual products.
Each tax rule contains a default tax rate and can optionally contain additional rules that depend
on the customer's country and type.
{% endblocktrans %}
</p>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Usage" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a
href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% blocktrans trimmed count count=tr.c_items %}
{{ count }} product
{% plural %}
{{ count }} products
{% endblocktrans %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate %}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate %}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.has_custom_rules %}
<br><small>{% trans "with custom rules" %}</small>
{% elif tr.eu_reverse_charge %}
<br><small>{% trans "reverse charge enabled" %}</small>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}"
class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="5">
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
</fieldset>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<fieldset>
<legend>{% trans "Tax settings" %}</legend>
{% bootstrap_field form.tax_rounding layout="control" %}
{% bootstrap_field form.display_net_prices layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,77 +0,0 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% block title %}{% trans "Tax rules" %}{% endblock %}
{% block inside %}
<h1>{% trans "Tax rules" %}</h1>
{% if taxrules|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any tax rules yet.
{% endblocktrans %}
</p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}</a>
</div>
{% else %}
<p>
<a href="{% url "control:event.settings.tax.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new tax rule" %}
</a>
</p>
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for tr in taxrules %}
<tr>
<td>
<strong><a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %}
{% else %}
{% blocktrans with rate=tr.rate%}excl. {{ rate }} %{% endblocktrans %}
{% endif %}
{% if tr.eu_reverse_charge %}
({% trans "reverse charge enabled" %})
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:event.settings.tax.edit" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.settings.tax.delete" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}
+1 -1
View File
@@ -286,7 +286,7 @@ urlpatterns = [
re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'), re_path(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'), re_path(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'), re_path(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
re_path(r'^settings/tax/$', event.TaxList.as_view(), name='event.settings.tax'), re_path(r'^settings/tax/$', event.TaxSettings.as_view(), name='event.settings.tax'),
re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'), re_path(r'^settings/tax/(?P<rule>\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'),
re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'), re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'),
re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'), re_path(r'^settings/tax/(?P<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
+21 -12
View File
@@ -54,7 +54,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError from django.db.models import Count, ProtectedError
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
@@ -87,7 +87,7 @@ from pretix.control.forms.event import (
EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm, EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm,
EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm, EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm,
MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm, MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm,
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TaxSettingsForm,
TicketSettingsForm, WidgetCodeForm, TicketSettingsForm, WidgetCodeForm,
) )
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
@@ -620,6 +620,25 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
return context return context
class TaxSettings(EventSettingsViewMixin, EventSettingsFormView):
template_name = 'pretixcontrol/event/tax.html'
form_class = TaxSettingsForm
permission = 'can_change_event_settings'
def get_success_url(self) -> str:
return reverse('control:event.settings.tax', kwargs={
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['taxrules'] = self.request.event.tax_rules.annotate(
c_items=Count("item")
).all()
return context
class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView): class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
model = Event model = Event
form_class = InvoiceSettingsForm form_class = InvoiceSettingsForm
@@ -1219,16 +1238,6 @@ class EventComment(EventPermissionRequiredMixin, View):
}) })
class TaxList(EventSettingsViewMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):
model = TaxRule
context_object_name = 'taxrules'
template_name = 'pretixcontrol/event/tax_index.html'
permission = 'can_change_event_settings'
def get_queryset(self):
return self.request.event.tax_rules.all()
class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView): class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView):
model = TaxRule model = TaxRule
form_class = TaxRuleForm form_class = TaxRuleForm