Compare commits

..

10 Commits

Author SHA1 Message Date
pajowu 7f847c3dd2 Fix TypeError if task.request.headers is None (#6367) 2026-07-09 18:52:49 +02:00
luelista 430c6dd269 Use SafeStrings for plugin signals returning HTML that should be rendered (#6343)
As a security precaution, we change the contract of some signals such that a 
SafeString needs to be returned if HTML should be rendered without further 
escaping.

Before, the `{% signal ... %}` and `{% eventsignal ... %}` template tags 
called mark_safe themselves on all strings returned from signals. That could
lead to unsafe coding practices, where untrusted values are interpolated into
HTML format strings. However, such interpolations should usually be performed 
using helpers such Django's format_html, which automatically escapes inputs
and returns a SafeString.
Now, we call conditional_escape on signal results, so that any HTML not explicitly
marked as safe gets escaped.

Most plugins are not affected by this change as they return a SafeString as a 
result of Template.render already.
2026-07-09 17:50:26 +02:00
Raphael Michel 6411648457 Revert "Update redis requirement from ==7.4.* to ==8.0.* (#6226)"
This reverts commit 005b3864b1.
2026-07-09 00:45:32 +02:00
dependabot[bot] 005b3864b1 Update redis requirement from ==7.4.* to ==8.0.* (#6226)
* Update redis requirement from ==7.4.* to ==8.0.*

Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v7.4.0...v8.0.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 8.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Remove setex calls

* Update src/pretix/presale/views/user.py

Co-authored-by: luelista <weller@rami.io>

* Fix tests

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@pretix.eu>
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: luelista <weller@rami.io>
2026-07-08 12:07:54 +02:00
Raphael Michel 1ee1c604cf Tax rules: Correctly write log when changing custom rules (#6358) 2026-07-08 11:33:06 +02:00
luelista e425105dbf Fix link from voucher tag to filtered voucher list (#6362) 2026-07-08 11:26:37 +02:00
Martin Gross 738b375042 DevDocs: Bump nodejs to 24 2026-07-08 10:53:14 +02:00
sweenu d09572d999 Remove leftovers from contact url in quick setup (#6359) 2026-07-07 19:23:48 +02:00
Lukas Bockstaller 634754aacb repair sendmail rules so we don't send emails to all positions in order independent of checkin status (Z#23235255) (#6340)
* add repro for Z#23235255

* add testcase for fallback to order.email

* only send to orderpositions included in op_qs

* codestyle
2026-07-07 17:46:52 +02:00
Raphael Michel 14c3baa2aa Drop nullability on Order.organizer and OrderPosition.organizer (#4278)
* Drop nullability on Order.organizer and OrderPosition.organizer

* Rebase migration and add autoclean

* Utilize new relationship for scopes

* Declare reverse noop

* Update src/pretix/base/migrations/0302_resolve_duplicate_codes_and_secrets.py

Co-authored-by: Martin Gross <gross@rami.io>

* Update src/pretix/base/migrations/0302_resolve_duplicate_codes_and_secrets.py

Co-authored-by: Martin Gross <gross@rami.io>

---------

Co-authored-by: Martin Gross <gross@rami.io>
2026-07-07 17:44:19 +02:00
25 changed files with 303 additions and 170 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ Working with the code
---------------------
If you do not have a recent installation of ``nodejs``, install it now::
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install nodejs
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
@@ -0,0 +1,58 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import logging
from django.db import migrations
from django.db.models import Count
logger = logging.getLogger(__name__)
def clean_duplicate_secrets(apps, schema_editor):
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
# unless Order.code is already too long to append something. This would need to be fixed by
# sysadmins manually.
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
Order = apps.get_model("pretixbase", "Order")
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = OrderPosition.all.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
a.secret = a.secret + "__dupl__" + str(a.pk)
logger.info(
f"Ticket {a.pk} has new secret {a.secret}"
)
a.save(update_fields=["organizer_id", "secret"])
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = Order.objects.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
if len(a.code) > 16 - len(str(a.pk)):
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
logger.info(
f"Order {a.pk} has new code {a.code}"
)
a.save(update_fields=["organizer_id", "code"])
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0301_reusablemedium_remove_orderposition",
),
]
operations = [
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
]
@@ -0,0 +1,46 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0302_resolve_duplicate_codes_and_secrets",
),
]
operations = [
migrations.RunSQL(
"UPDATE pretixbase_order "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
"WHERE pretixbase_order.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.RunSQL(
"UPDATE pretixbase_orderposition "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
]
+6 -9
View File
@@ -1403,15 +1403,12 @@ class Event(EventMixin, LoggedModel):
for mp in self.organizer.meta_properties.all():
if mp.required and not self.meta_data.get(mp.name):
issues.append(
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
property=mp.name,
a_attr='href="%s#id_prop-%d-value"' % (
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
mp.pk
)
)
)
issues.append(format_html(
'<a href="{href}{href_hash}">{text}</a>',
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name),
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
href_hash=f'#id_prop-{mp.pk}-value',
))
responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
+2 -6
View File
@@ -224,8 +224,6 @@ class Order(LockModel, LoggedModel):
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
@@ -329,7 +327,7 @@ class Order(LockModel, LoggedModel):
default="line",
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
class Meta:
verbose_name = _("Order")
@@ -2541,8 +2539,6 @@ class OrderPosition(AbstractPosition):
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
@@ -2599,7 +2595,7 @@ class OrderPosition(AbstractPosition):
blank=True,
)
all = ScopedManager(organizer='order__event__organizer')
all = ScopedManager(organizer='organizer')
objects = ActivePositionManager()
def __init__(self, *args, **kwargs):
+3 -2
View File
@@ -535,8 +535,9 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal()
"""
This signal is sent out to determine whether an event can be taken live. If you want to
prevent the event from going live, return a string that will be displayed to the user
as the error message. If you don't, your receiver should return ``None``.
prevent the event from going live, return an error message to display to the user (either
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't,
your receiver should return ``None``.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
+3 -2
View File
@@ -22,6 +22,7 @@
import importlib
from django import template
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from pretix.base.models import Event
@@ -44,7 +45,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
_html = []
for receiver, response in signal.send(event, **kwargs):
if response:
_html.append(response)
_html.append(conditional_escape(response))
return mark_safe("".join(_html))
@@ -63,5 +64,5 @@ def signal(signame: str, request, **kwargs):
_html = []
for receiver, response in signal.send(request, **kwargs):
if response:
_html.append(response)
_html.append(conditional_escape(response))
return mark_safe("".join(_html))
+1 -1
View File
@@ -59,7 +59,7 @@ def on_task_prerun(sender, task_id, task, **kwargs):
from pretix.helpers.logs import local
local.request_id = task_id
if "X-Pretix-Trace" in task.request.headers:
if task.request.headers and "X-Pretix-Trace" in task.request.headers:
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
else:
local.trace = []
-6
View File
@@ -1905,12 +1905,6 @@ class QuickSetupForm(I18nForm):
required=False,
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
contact_url = forms.URLField(
label=_("Contact URL"),
required=False,
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
total_quota = forms.IntegerField(
label=_("Total capacity"),
min_value=0,
+8
View File
@@ -173,6 +173,14 @@ def get_event_navigation(request: HttpRequest):
}),
'active': 'event.items.quota' in url.url_name,
},
{
'label': _('Categories'),
'url': reverse('control:event.items.categories', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.items.categories' in url.url_name,
},
{
'label': _('Questions'),
'url': reverse('control:event.items.questions', kwargs={
+12 -4
View File
@@ -39,7 +39,8 @@ from pretix.base.signals import (
html_page_start = GlobalSignal()
"""
This signal allows you to put code in the beginning of the main page for every
page in the backend. You are expected to return HTML.
page in the backend. You are expected to return a SafeString containing HTML, or
a string that will be HTML-escaped.
The ``sender`` keyword argument will contain the request.
"""
@@ -129,7 +130,7 @@ event_dashboard_top = EventPluginSignal()
Arguments: 'request'
This signal is sent out to include custom HTML in the top part of the the event dashboard.
Receivers should return HTML.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
An additional keyword argument ``subevent`` *can* contain a sub-event.
@@ -172,6 +173,7 @@ Arguments: 'form'
This signal allows you to add additional HTML to the form that is used for modifying vouchers.
You receive the form object in the ``form`` keyword argument.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -209,6 +211,7 @@ Arguments: 'quota'
This signal allows you to append HTML to a Quota's detail view. You receive the
quota as argument in the ``quota`` keyword argument.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -219,6 +222,7 @@ Arguments: 'subevent'
This signal allows you to append HTML to a SubEvent's detail view. You receive the
subevent as argument in the ``subevent`` keyword argument.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -265,7 +269,8 @@ order_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order detail page
This signal is sent out to display additional information on the order detail page.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -275,7 +280,8 @@ order_approve_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order approve page
This signal is sent out to display additional information on the order approve page.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -286,6 +292,7 @@ order_position_buttons = EventPluginSignal()
Arguments: ``order``, ``position``, ``request``
This signal is sent out to display additional buttons for a single position of an order.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
Additionally, the argument ``order`` and ``request`` are available.
@@ -315,6 +322,7 @@ Arguments: 'request'
This signal is sent out to include template snippets on the settings page of an event
that allows generating a pretix Widget code.
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object.
@@ -19,7 +19,7 @@
</p>
<ul>
{% for issue in issues %}
<li>{{ issue|safe }}</li>
<li>{{ issue }}</li>
{% endfor %}
</ul>
</div>
@@ -42,7 +42,7 @@
</p>
<ul>
{% for issue in issues %}
<li>{{ issue|safe }}</li>
<li>{{ issue }}</li>
{% endfor %}
</ul>
</div>
@@ -193,7 +193,6 @@
{% endblocktrans %}
</p>
{% bootstrap_field form.contact_mail layout="control" %}
{% bootstrap_field form.contact_url layout="control" %}
{% bootstrap_field form.imprint_url layout="control" %}
</div>
</fieldset>
@@ -45,9 +45,6 @@
{% endif %}
</div>
<div class="form-group submit-group">
{% if category %}
<a title="{% trans "Delete" %}" href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=category.id %}" class="btn btn-danger btn-lg pull-left"><i class="fa fa-trash"></i> {% trans "Delete" %}</a>
{% endif %}
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
@@ -10,7 +10,7 @@
Are you sure you want to delete the category <strong>{{ name }}</strong>?
{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:event.items" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
<a href="{% url "control:event.items.categories" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
@@ -2,7 +2,6 @@
{% load i18n %}
{% load money %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Products" %}{% endblock %}
{% block inside %}
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
@@ -24,24 +23,19 @@
{% if 'event.items:write' in request.eventpermset %}
<a href="{% url "control:event.items.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 product" %}</a>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new category" %}</a>
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</a>
{% endif %}
</div>
{% else %}
<form method="post" id="products_table">
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</a>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new category" %}</a>
<button class="btn btn-default pull-right" id="btn_reorder_categories"><i class="fa fa-arrows"></i> {% trans "Reorder categories" %}</button>
</p>
{% endif %}
{% if 'event.items:write' in request.eventpermset %}
<p>
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</a>
</p>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<div class="table-responsive">
<table class="table table-condensed table-hover table-items">
<thead>
<tr>
@@ -188,66 +182,8 @@
</tbody>
{% endfor %}
</table>
</div>
</form>
<form method="post" id="categories_table" hidden>
<p>
<button disabled
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new product" %}</button>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new category" %}</a>
<button class="btn btn-primary pull-right" id="btn_reorder_categories_done"><i class="fa fa-check"></i> {% trans "Done reordering categories" %}</button>
</p>
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Product categories" %}</th>
<th>{% trans "Category type" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
{% for c, items in cat_list %}{% if c %}
<tr data-dnd-id="{{ c.id }}">
<td>
{% if 'event.items:write' in request.eventpermset %}
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
{% else %}
<strong>{{ c.internal_name|default:c.name }}</strong>
{% endif %}
<br>
<small class="text-muted">
#{{ c.pk }}
</small>
</td>
<td>
{{ c.get_category_type_display }}
</td>
<td class="text-right flip">
{% if 'event.items:write' in request.eventpermset %}
<button title="{% trans "Move up" %}" formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
<a title="{% trans "Edit" %}" href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a title="{% trans "Delete" %}" href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
</div>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/items.js" %}"></script>
{% endcompress %}
{% endblock %}
@@ -49,11 +49,11 @@
<td>
<strong>
{% if t.tag %}
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '"'|add:t.tag|add:'"'|urlencode }}">
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?filter-tag={{ '"'|add:t.tag|add:'"'|urlencode }}">
{{ t.tag }}
</a>
{% else %}
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '<>'|urlencode }}">
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?filter-tag={{ '<>'|urlencode }}">
{% trans "Empty tag" %}
</a>
{% endif %}
+1
View File
@@ -329,6 +329,7 @@ urlpatterns = [
re_path(r'^items/select2/itemvar$', typeahead.itemvar_select2, name='event.items.itemvar.select2'),
re_path(r'^items/select2/itemvars$', typeahead.itemvars_select2, name='event.items.itemvars.select2'),
re_path(r'^items/select2/variation$', typeahead.variations_select2, name='event.items.variations.select2'),
re_path(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'),
re_path(r'^categories/select2$', typeahead.category_select2, name='event.items.categories.select2'),
re_path(r'^categories/(?P<category>\d+)/delete$', item.CategoryDelete.as_view(),
name='event.items.categories.delete'),
+9 -4
View File
@@ -1428,11 +1428,16 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
form.instance.custom_rules = json.dumps([
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
], cls=I18nJSONEncoder)
if form.has_changed():
if form.has_changed() or self.formset.has_changed():
change_data = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
if self.formset.has_changed():
change_data["custom_rules"] = [
f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms
]
self.object.log_action(
'pretix.event.taxrule.changed', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
'pretix.event.taxrule.changed', user=self.request.user, data=change_data
)
return super().form_valid(form)
+5 -5
View File
@@ -244,7 +244,7 @@ class CategoryDelete(EventPermissionRequiredMixin, CompatDeleteView):
return HttpResponseRedirect(success_url)
def get_success_url(self) -> str:
return reverse('control:event.items', kwargs={
return reverse('control:event.items.categories', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@@ -278,7 +278,7 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView):
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse('control:event.items', kwargs={
return reverse('control:event.items.categories', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@@ -296,7 +296,7 @@ class CategoryCreate(EventPermissionRequiredMixin, CreateView):
context_object_name = 'category'
def get_success_url(self) -> str:
return reverse('control:event.items', kwargs={
return reverse('control:event.items.categories', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@@ -380,7 +380,7 @@ def category_move(request, category, up=True):
@require_http_methods(["POST"])
def category_move_up(request, organizer, event, category):
category_move(request, category, up=True)
return redirect('control:event.items',
return redirect('control:event.items.categories',
organizer=request.event.organizer.slug,
event=request.event.slug)
@@ -389,7 +389,7 @@ def category_move_up(request, organizer, event, category):
@require_http_methods(["POST"])
def category_move_down(request, organizer, event, category):
category_move(request, category, up=False)
return redirect('control:event.items',
return redirect('control:event.items.categories',
organizer=request.event.organizer.slug,
event=request.event.slug)
+26 -23
View File
@@ -162,6 +162,8 @@ class ScheduledMail(models.Model):
send_to_orders = self.rule.send_to in (Rule.CUSTOMERS, Rule.BOTH)
send_to_attendees = self.rule.send_to in (Rule.ATTENDEES, Rule.BOTH)
position_ids = op_qs.values_list('id', flat=True)
for o in orders:
with language(o.locale, e.settings.region):
positions = list(o.positions.all())
@@ -191,28 +193,29 @@ class ScheduledMail(models.Model):
positions = [p for p in positions if p.subevent_id == self.subevent_id]
for p in positions:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
if p.id in position_ids:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
self.last_successful_order_id = o.pk
@@ -270,7 +273,7 @@ class Rule(models.Model, LoggingMixin):
date_is_absolute = models.BooleanField(default=True, blank=True)
offset_to_event_end = models.BooleanField(default=False, blank=True) # no verbose name because not actually
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
send_to = models.CharField(max_length=10, choices=SEND_TO_CHOICES, default=CUSTOMERS, verbose_name=_('Send email to'))
+16 -10
View File
@@ -161,7 +161,8 @@ voucher_redeem_info = EventPluginSignal()
"""
Arguments: ``voucher``
This signal is sent out to display additional information on the "redeem a voucher" page
This signal is sent out to display additional information on the "redeem a voucher" page.
You are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -194,6 +195,7 @@ Arguments: ``request``
This signals allows you to add HTML content to the confirmation page that is presented at the
end of the checkout process, just before the order is being created.
You are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. A ``request``
argument will contain the request object.
@@ -276,7 +278,8 @@ order_info = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on the order detail page
This signal is sent out to display additional information on the order detail page.
Return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -285,7 +288,8 @@ position_info = EventPluginSignal()
"""
Arguments: ``order``, ``position``, ``request``
This signal is sent out to display additional information on the position detail page
This signal is sent out to display additional information on the position detail page.
Return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -294,7 +298,8 @@ order_info_top = EventPluginSignal()
"""
Arguments: ``order``, ``request``
This signal is sent out to display additional information on top of the order detail page
This signal is sent out to display additional information on top of the order detail page.
Return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -303,7 +308,8 @@ position_info_top = EventPluginSignal()
"""
Arguments: ``order``, ``position``, ``request``
This signal is sent out to display additional information on top of the position detail page
This signal is sent out to display additional information on top of the position detail page.
Return a SafeString containing HTML, or a string that will be HTML-escaped.
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -349,7 +355,7 @@ This signal is sent out to display additional information on the frontpage above
of products and but below a custom frontpage text.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
receivers are expected to return HTML.
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
"""
render_seating_plan = EventPluginSignal()
@@ -361,7 +367,7 @@ You will be passed the ``request`` as a keyword argument. If applicable, a ``sub
``voucher`` argument might be given.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
receivers are expected to return HTML.
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
"""
front_page_bottom = EventPluginSignal()
@@ -372,7 +378,7 @@ This signal is sent out to display additional information on the frontpage below
of products.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
receivers are expected to return HTML.
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
"""
front_page_bottom_widget = EventPluginSignal()
@@ -383,7 +389,7 @@ This signal is sent out to display additional information on the frontpage below
of products if the front page is shown in the widget.
As with all event plugin signals, the ``sender`` keyword argument will contain the event. The
receivers are expected to return HTML.
receivers are expected to return a SafeString containing HTML, or a string that will be HTML-escaped.
"""
checkout_all_optional = EventPluginSignal()
@@ -403,7 +409,7 @@ Arguments: ``item``, ``variation``, ``subevent``
This signal is sent out when the description of an item or variation is rendered and allows you to append
additional text to the description. You are passed the ``item``, ``variation`` and ``subevent``. You are
expected to return HTML.
expected to return markdown.
"""
register_cookie_providers = EventPluginSignal()
@@ -1,11 +0,0 @@
$("#btn_reorder_categories").click(function() {
$("#products_table").attr('hidden', '');
$("#categories_table").removeAttr('hidden');
return false;
});
$("#btn_reorder_categories_done").click(function() {
//$("#products_table").removeAttr('hidden');
//$("#categories_table").attr('hidden', '');
location=location;
return false;
});
@@ -810,9 +810,6 @@ tbody[data-dnd-url] {
display: table-row;
height: 0.5em;
}
.table-items thead + tbody[data-dnd-url]:not(:has(tr))::after {
height: 0; /* no spacing before first header, if no items without category */
}
.sortable-sorting tbody:not(.sortable-dragarea) {
opacity: .4;
}
+91
View File
@@ -426,6 +426,97 @@ def test_sendmail_rule_checked_in_get_mail(event, order, item):
assert len(djmail.outbox) == 1, "email not sent"
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_checked_in_mixed_order(event, order, item):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="checked_in",
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order(event, order, item):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email_not_matching_status(event, order, item, item2):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
# we have no email and we are checked in
# we shouldn't trigger a fallback to order.email
p3 = order.all_positions.create(item=item, price=13)
perform_checkin(p3, clist, {})
assert clist.checkin_count == 2
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
recipients = [m.to for m in djmail.outbox]
assert [p2.attendee_email] in recipients # for p2
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email(event, order, item, item2):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
order.all_positions.create(item=item, price=13) # p3
order.all_positions.create(item=item, price=13) # p4
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
sendmail_run_rules(None)
assert len(djmail.outbox) == 2
recipients = [m.to for m in djmail.outbox]
assert [order.email] in recipients # for p3 and p4
assert [p2.attendee_email] in recipients # for p2
@pytest.mark.django_db
@scopes_disabled()
def run_restriction_test(event, order, restrictions_pass=[], restrictions_fail=[]):