Compare commits

...
Author SHA1 Message Date
Raphael Michel 6b7f2ad684 API: Fix PATCH on item addon sets without min_count or max_count (fixes #6650) (#6651) 2026-10-10 09:53:49 +02:00
dependabot[bot] 54b249acf5 Update pycryptodome requirement from ==3.23.* to ==3.24.* (#6657)
Updates the requirements on [pycryptodome](https://github.com/Legrandin/pycryptodome) to permit the latest version.
- [Release notes](https://github.com/Legrandin/pycryptodome/releases)
- [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst)
- [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.23.0...v3.24.0)

---
updated-dependencies:
- dependency-name: pycryptodome
  dependency-version: 3.24.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 22:56:43 +02:00
dependabot[bot] 0cf2ba763b Update sphinxcontrib-httpdomain requirement from ~=2.0.0 to ~=2.1.0 (#6660)
Updates the requirements on [sphinxcontrib-httpdomain](https://github.com/sphinx-contrib/httpdomain) to permit the latest version.
- [Release notes](https://github.com/sphinx-contrib/httpdomain/releases)
- [Changelog](https://github.com/sphinx-contrib/httpdomain/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/sphinx-contrib/httpdomain/compare/2.0.0...2.1.0)

---
updated-dependencies:
- dependency-name: sphinxcontrib-httpdomain
  dependency-version: 2.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 22:56:27 +02:00
dependabot[bot] b44c3d707e Update fakeredis requirement from ==2.38.* to ==2.39.* (#6654)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.38.0...v2.39.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.39.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 22:56:17 +02:00
dependabot[bot] 45603c6624 Update css-inline requirement from ==0.21.* to ==0.22.* (#6655)
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/c-v0.21.0...c-v0.22.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-version: 0.22.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 22:56:13 +02:00
Richard Schreiber ba36d649c6 Fix sendmail migration activating plugin on events (#6656) 2026-10-08 12:35:41 +02:00
Richard Schreiber ca90b2af09 Fix prefix for event notifications (#6653) 2026-10-08 10:09:38 +02:00
Phin Wolkwitz fe43862fa4 Sendmail: Handle scheduled mails in inactive plugin (Z#23243588) (#6587)
* Activate plugin where rules are currently active (migration)
* Check if plugin is active before sending mails
* Disable rules when the plugin gets (manually) uninstalled
2026-10-07 10:33:16 +02:00
Raphael MichelandRichard Schreiber 411cd764a6 Order changes: Log more information about taxes (#6637)
* Order changes: Log more information about taxes

* Apply batched suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Add to tax_rule change as well

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2026-10-07 09:28:35 +02:00
6618e3a039 Organizer-level notifications (#6462)
* Organizer-level notifications

* Update signals

* Update signal docs

* Add tests

* Apply batched suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Update src/pretix/base/models/organizer.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-10-07 09:28:15 +02:00
Richard Schreiber b7cd97c33e API: fix payment confirm if request.data is empty string (#6634)
* API: fix payment confirm if request.data is empty string

* fix fallback value
2026-10-07 09:21:30 +02:00
dependabot[bot] eae3229e68 Update pytest-mock requirement from ==3.15.* to ==3.16.* (#6628)
Updates the requirements on [pytest-mock](https://github.com/pytest-dev/pytest-mock) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-mock/releases)
- [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.15.0...v3.16.0)

---
updated-dependencies:
- dependency-name: pytest-mock
  dependency-version: 3.16.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-07 09:13:05 +02:00
19 changed files with 263 additions and 58 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ Core
---- ----
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, :members: periodic_task, event_live_issues, event_copy_data, email_filter,
register_notification_types, event_notification_sent, organizer_notification_sent,
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display, register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders, device_info_updated, register_text_placeholders, register_mail_placeholders, device_info_updated,
+1 -1
View File
@@ -1,6 +1,6 @@
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0 sphinxcontrib-httpdomain~=2.1.0
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-spelling~=8.0.2
+1 -1
View File
@@ -1,7 +1,7 @@
-e ../ -e ../
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0 sphinxcontrib-httpdomain~=2.1.0
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-spelling~=8.0.2
+4 -4
View File
@@ -34,7 +34,7 @@ dependencies = [
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=50.0.2", "cryptography>=50.0.2",
"css-inline==0.21.*", "css-inline==0.22.*",
"defusedcsv>=3.0.0", "defusedcsv>=3.0.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==5.2.*,>=5.2.17", "Django[argon2]==5.2.*,>=5.2.17",
@@ -83,7 +83,7 @@ dependencies = [
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==3.0", "pycparser==3.0",
"pycryptodome==3.23.*", "pycryptodome==3.24.*",
"pypdf==6.19.*", "pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab "python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*", "python-dateutil==2.9.*",
@@ -112,7 +112,7 @@ dev = [
"aiohttp==3.14.*", "aiohttp==3.14.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.38.*", "fakeredis==2.39.*",
"flake8==7.4.*", "flake8==7.4.*",
"freezegun", "freezegun",
"isort==9.0.*", "isort==9.0.*",
@@ -122,7 +122,7 @@ dev = [
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
"pytest-mock==3.15.*", "pytest-mock==3.16.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.8.*", "pytest-xdist==3.8.*",
"pytest-playwright", "pytest-playwright",
+3 -1
View File
@@ -261,7 +261,9 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count')) full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
ItemAddOn.clean_max_min_count(full_data.get('max_count'), full_data.get('min_count'))
return data return data
+1 -1
View File
@@ -1683,7 +1683,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def confirm(self, request, **kwargs): def confirm(self, request, **kwargs):
payment = self.get_object() payment = self.get_object()
force = request.data.get('force', False) force = request.data.get('force', False) if request.data else False
send_mail = request.data.get('send_email', True) if request.data else True send_mail = request.data.get('send_email', True) if request.data else True
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
+20 -1
View File
@@ -42,7 +42,7 @@ from django.conf import settings
from django.core.mail import get_connection from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -312,6 +312,25 @@ class Organizer(LoggedModel):
) )
i += 1 i += 1
def get_users_with_permission(self, permission):
"""
Returns a queryset of users who have a specific permission to this organizer.
:return: Iterable of User
"""
from .auth import User
if permission:
qs = Team.objects.with_organizer_permission(permission)
else:
qs = Team.objects.all()
team_with_perm = qs.filter(
members__pk=OuterRef('pk'),
organizer=self,
)
return User.objects.filter(Exists(team_with_perm))
def generate_invite_token(): def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
+34 -11
View File
@@ -35,12 +35,13 @@
import logging import logging
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from itertools import groupby from itertools import groupby
from typing import Optional, Union
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Event, LogEntry from pretix.base.models import Event, LogEntry, Organizer
from pretix.base.signals import register_notification_types from pretix.base.signals import register_notification_types
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import mainreverse_absolute
@@ -57,7 +58,8 @@ class Notification:
""" """
Represents a notification that is sent/shown to a user. A notification consists of: Represents a notification that is sent/shown to a user. A notification consists of:
* one ``event`` reference * one ``event`` reference (can be ``Ǹone``)
* one ``organizer`` reference
* one ``title`` text that is shown e.g. in the email subject or in a headline * one ``title`` text that is shown e.g. in the email subject or in a headline
* optionally one ``detail`` text that may or may not be shown depending on the notification method * optionally one ``detail`` text that may or may not be shown depending on the notification method
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order) * optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
@@ -67,8 +69,12 @@ class Notification:
each consisting of a button label and an absolute URL to point to. each consisting of a button label and an absolute URL to point to.
""" """
def __init__(self, event: Event, title: str, detail: str=None, url: str=None): def __init__(self, event: Optional[Event], title: str, detail: Optional[str]=None, url: Optional[str]=None, organizer: Optional[Organizer]=None):
assert event or organizer, "You need to pass either event or organizer"
if event and organizer:
assert event.organizer_id == organizer.id, "Organizer and event mismatch"
self.title = title self.title = title
self.organizer = organizer or event.organizer
self.event = event self.event = event
self.detail = detail self.detail = detail
self.url = url self.url = url
@@ -91,8 +97,16 @@ class Notification:
class NotificationType: class NotificationType:
def __init__(self, event: Event = None): def __init__(self, event_or_organizer: Union[Event, Organizer] = None):
self.event = event if isinstance(event_or_organizer, Event):
self.event = event_or_organizer
self.organizer = event_or_organizer.organizer
elif isinstance(event_or_organizer, Organizer):
self.event = None
self.organizer = event_or_organizer
else:
self.event = None
self.organizer = None
def __repr__(self): def __repr__(self):
return '<NotificationType: {}>'.format(self.action_type) return '<NotificationType: {}>'.format(self.action_type)
@@ -121,31 +135,39 @@ class NotificationType:
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
@property
def is_event_level(self) -> bool:
"""
Return `True` if this notification type can be configured per event (the default).
"""
return True
def build_notification(self, logentry: LogEntry) -> Notification: def build_notification(self, logentry: LogEntry) -> Notification:
""" """
This is the main function that you should override. It is supposed to turn a log entry This is the main function that you should override. It is supposed to turn a log entry
object into a notification object that can then be rendered e.g. into an email. object into a notification object that can then be rendered e.g. into an email.
""" """
return Notification( return Notification(
logentry.event, event=logentry.event,
logentry.display() title=logentry.display(),
organizer=logentry.organizer,
) )
def get_all_notification_types(event=None): def get_all_notification_types(event_or_organizer=None):
global _ALL_TYPES global _ALL_TYPES
if event is None and _ALL_TYPES: if event_or_organizer is None and _ALL_TYPES:
return _ALL_TYPES return _ALL_TYPES
types = OrderedDict() types = OrderedDict()
for recv, ret in register_notification_types.send(event): for recv, ret in register_notification_types.send(event_or_organizer):
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
for r in ret: for r in ret:
types[r.action_type] = r types[r.action_type] = r
else: else:
types[ret.action_type] = ret types[ret.action_type] = ret
if event is None: if event_or_organizer is None:
_ALL_TYPES = types _ALL_TYPES = types
return types return types
@@ -181,6 +203,7 @@ class ParametrizedOrderNotificationType(NotificationType):
n = Notification( n = Notification(
event=logentry.event, event=logentry.event,
organizer=logentry.organizer,
title=self._title.format(order=order, event=logentry.event), title=self._title.format(order=order, event=logentry.event),
url=order_url url=order_url
) )
+37 -18
View File
@@ -34,7 +34,9 @@ from pretix.base.models import (
from pretix.base.notifications import Notification, get_all_notification_types from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import notification from pretix.base.signals import (
event_notification_sent, organizer_notification_sent,
)
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.celery import get_task_priority from pretix.helpers.celery import get_task_priority
from pretix.helpers.urls import mainreverse_absolute from pretix.helpers.urls import mainreverse_absolute
@@ -47,40 +49,47 @@ def notify(logentry_ids: list):
logentry_ids = [logentry_ids] logentry_ids = [logentry_ids]
qs = LogEntry.all.select_related( qs = LogEntry.all.select_related(
'event', 'event__organizer' 'event', 'event__organizer', 'organizer'
).order_by( ).order_by(
'action_type', 'event_id', 'action_type', 'event_id', 'organizer_id',
).filter(id__in=logentry_ids) ).filter(id__in=logentry_ids)
_event, _at, notify_specific, notify_global = None, None, None, None _event, _organizer, _at, notify_specific, notify_global = None, None, None, None, None
for logentry in qs: for logentry in qs:
if not logentry.event: if not logentry.event and not logentry.organizer:
break # Ignore, we only have event-related notifications right now break # Ignore, we only have event- or organizer-related notifications right now
notification_type = logentry.notification_type notification_type = logentry.notification_type
if not notification_type: if not notification_type:
break # No suitable plugin break # No suitable plugin
if _event != logentry.event or _at != logentry.action_type or notify_global is None: if _event != logentry.event or _organizer != logentry.organizer or _at != logentry.action_type or notify_global is None:
_event = logentry.event _event = logentry.event
_organizer = logentry.organizer
_at = logentry.action_type _at = logentry.action_type
event_or_organizer = logentry.event or logentry.organizer
# All users that have the permission to get the notification # All users that have the permission to get the notification
users = logentry.event.get_users_with_permission( users = event_or_organizer.get_users_with_permission(
notification_type.required_permission notification_type.required_permission
).filter(notifications_send=True, is_active=True) ).filter(notifications_send=True, is_active=True)
if logentry.user: if logentry.user:
users = users.exclude(pk=logentry.user.pk) users = users.exclude(pk=logentry.user.pk)
# Get all notification settings, both specific to this event as well as global # Get all notification settings, both specific to this event as well as global
notify_specific = { if logentry.event:
(ns.user, ns.method): ns.enabled notify_specific = {
for ns in NotificationSetting.objects.filter( (ns.user, ns.method): ns.enabled
event=logentry.event, for ns in NotificationSetting.objects.filter(
action_type=notification_type.action_type, event=logentry.event,
user__pk__in=users.values_list('pk', flat=True) action_type=notification_type.action_type,
) user__pk__in=users.values_list('pk', flat=True)
} )
}
else:
notify_specific = {}
notify_global = { notify_global = {
(ns.user, ns.method): ns.enabled (ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter( for ns in NotificationSetting.objects.filter(
@@ -106,7 +115,10 @@ def notify(logentry_ids: list):
priority=get_task_priority("notifications", logentry.organizer_id), priority=get_task_priority("notifications", logentry.organizer_id),
) )
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type) if logentry.event:
event_notification_sent.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
elif logentry.organizer:
organizer_notification_sent.send(logentry.organizer, logentry_id=logentry.id, notification_type=notification_type.action_type)
@app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900) @app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900)
@@ -158,13 +170,20 @@ def send_notification_mail(notification: Notification, user: User):
body_plain = tpl_plain.render(ctx) body_plain = tpl_plain.render(ctx)
guid = uuid.uuid4() guid = uuid.uuid4()
settings_holder = notification.event or notification.organizer
prefix = settings_holder.settings.mail_prefix
if not prefix:
if notification.event:
prefix = notification.event.slug.upper()
elif notification.organizer:
prefix = notification.organizer.name
m = OutgoingMail.objects.create( m = OutgoingMail.objects.create(
guid=guid, guid=guid,
user=user, user=user,
to=[user.email], to=[user.email],
subject='[{}] {}: {}'.format( subject='[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME, settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(), prefix,
notification.title notification.title
), ),
body_plain=body_plain, body_plain=body_plain,
+35 -9
View File
@@ -2474,7 +2474,11 @@ class OrderChangeManager:
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
'fee': fee.pk, 'fee': fee.pk,
'old_price': fee.value, 'old_price': fee.value,
'new_price': op.value.gross 'new_price': op.value.gross,
'old_tax_rate': fee.tax_rate,
'new_tax_rate': op.value.rate,
'old_tax_value': fee.tax_value,
'new_tax_value': op.value.tax,
}) })
fee.value = op.value.gross fee.value = op.value.gross
fee._calculate_tax() fee._calculate_tax()
@@ -2484,9 +2488,13 @@ class OrderChangeManager:
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
'position': position.pk, 'position': position.pk,
'positionid': position.positionid, 'positionid': position.positionid,
'old_price': position.price,
'addon_to': position.addon_to_id, 'addon_to': position.addon_to_id,
'new_price': op.price.gross 'old_price': position.price,
'new_price': op.price.gross,
'old_tax_rate': position.tax_rate,
'new_tax_rate': op.price.rate,
'old_tax_value': position.tax_value,
'new_tax_value': op.price.tax,
}) })
position.price = op.price.gross position.price = op.price.gross
position.price_includes_rounding_correction = Decimal("0.00") position.price_includes_rounding_correction = Decimal("0.00")
@@ -2501,25 +2509,43 @@ class OrderChangeManager:
elif isinstance(op, self.TaxRuleOperation): elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition): if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ log = {
'position': position.pk, 'position': position.pk,
'positionid': position.positionid, 'positionid': position.positionid,
'addon_to': position.addon_to_id, 'addon_to': position.addon_to_id,
'old_taxrule': position.tax_rule.pk if position.tax_rule else None, 'old_taxrule': position.tax_rule.pk if position.tax_rule else None,
'new_taxrule': op.tax_rule.pk 'new_taxrule': op.tax_rule.pk,
}) 'old_price': position.price,
'old_tax_rate': position.tax_rate,
'old_tax_value': position.tax_value,
}
position._calculate_tax(op.tax_rule) position._calculate_tax(op.tax_rule)
position.save() position.save()
log.update({
'new_price': position.price,
'new_tax_rate': position.tax_rate,
'new_tax_value': position.tax_value,
})
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
elif isinstance(op.position, OrderFee): elif isinstance(op.position, OrderFee):
fee = fee_cache.setdefault(op.position.pk, op.position) fee = fee_cache.setdefault(op.position.pk, op.position)
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ log = {
'fee': fee.pk, 'fee': fee.pk,
'fee_type': fee.fee_type, 'fee_type': fee.fee_type,
'old_taxrule': fee.tax_rule.pk if fee.tax_rule else None, 'old_taxrule': fee.tax_rule.pk if fee.tax_rule else None,
'new_taxrule': op.tax_rule.pk 'new_taxrule': op.tax_rule.pk,
}) 'old_price': fee.value,
'old_tax_rate': fee.tax_rate,
'old_tax_value': fee.tax_value,
}
fee._calculate_tax(op.tax_rule) fee._calculate_tax(op.tax_rule)
fee.save() fee.save()
log.update({
'new_price': fee.value,
'new_tax_rate': fee.tax_rate,
'new_tax_value': fee.tax_value,
})
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
elif isinstance(op, self.CancelFeeOperation): elif isinstance(op, self.CancelFeeOperation):
fee = fee_cache.setdefault(op.fee.pk, op.fee) fee = fee_cache.setdefault(op.fee.pk, op.fee)
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
+13 -5
View File
@@ -605,15 +605,14 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
register_notification_types = EventPluginSignal() register_notification_types = GlobalSignal()
""" """
This signal is sent out to get all known notification types. Receivers should return an This signal is sent out to get all known notification types. Receivers should return an
instance of a subclass of pretix.base.notifications.NotificationType or a list of such instance of a subclass of pretix.base.notifications.NotificationType or a list of such
instances. instances.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event, When called for actually sending notifications, ``sender`` will be the event or organizer,
however for this signal, the ``sender`` **may also be None** to allow creating the general depending on context.
notification settings!
""" """
register_event_permission_groups = GlobalSignal() register_event_permission_groups = GlobalSignal()
@@ -628,7 +627,7 @@ This signal is sent out to get all known permissions. Receivers should return an
instance of pretix.base.permissions.PermissionGroup or a list of such instances. instance of pretix.base.permissions.PermissionGroup or a list of such instances.
""" """
notification = EventPluginSignal() event_notification_sent = EventPluginSignal()
""" """
Arguments: ``logentry_id``, ``notification_type`` Arguments: ``logentry_id``, ``notification_type``
@@ -637,6 +636,15 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
organizer_notification_sent = OrganizerPluginSignal()
"""
Arguments: ``logentry_id``, ``notification_type``
This signal is sent out when a notification is sent.
As with all organizer-plugin signals, the ``sender`` keyword argument will contain the organizer.
"""
register_sales_channel_types = GlobalSignal() register_sales_channel_types = GlobalSignal()
""" """
This signal is sent out to get all known sales channels types. Receivers should return an This signal is sent out to get all known sales channels types. Receivers should return an
+1
View File
@@ -761,6 +761,7 @@ class UserNotificationsEditView(TemplateView):
{k: a.get(t) for k, a in self.global_set.items()}, {k: a.get(t) for k, a in self.global_set.items()},
) )
for t, tv in self.types.items() for t, tv in self.types.items()
if tv.is_event_level or not self.event
] ]
ctx['event'] = self.event ctx['event'] = self.event
if self.event: if self.event:
+3
View File
@@ -40,3 +40,6 @@ class SendMailApp(AppConfig):
def ready(self): def ready(self):
from . import signals # NOQA from . import signals # NOQA
from . import tasks # NOQA from . import tasks # NOQA
def uninstalled(self, event):
event.sendmail_rules.update(enabled=False)
@@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Exists, OuterRef
def activate_plugin(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
Rule = apps.get_model("sendmail", "Rule")
events = (
Event.objects
.exclude(plugins__icontains="pretix.plugins.sendmail")
.filter(
Exists(
ScheduledMail.objects.filter(event=OuterRef('pk'), rule__enabled=True).exclude(
state__in=['completed', 'missed'])
)
)
)
for event in events:
plugins_active = event.plugins.split(',')
plugins_active.append('pretix.plugins.sendmail')
event.plugins = ','.join(plugins_active)
event.save(update_fields=['plugins'])
class Migration(migrations.Migration):
dependencies = [
("sendmail", "0012_remove_cross_event_scheduled_mails"),
]
operations = [
migrations.RunPython(activate_plugin),
]
+1
View File
@@ -181,6 +181,7 @@ def sendmail_run_rules(sender, **kwargs):
state=ScheduledMail.STATE_MISSED state=ScheduledMail.STATE_MISSED
) )
for m_id in mails.filter( for m_id in mails.filter(
event__plugins__contains="pretix.plugins.sendmail",
state__in=(ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED), state__in=(ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED),
rule__enabled=True, rule__enabled=True,
event__live=True, event__live=True,
+8
View File
@@ -1925,6 +1925,14 @@ def test_addons_create(token_client, organizer, event, item, category, category2
@pytest.mark.django_db @pytest.mark.django_db
def test_addons_update(token_client, organizer, event, item, addon): def test_addons_update(token_client, organizer, event, item, addon):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, item.pk, addon.pk),
{
},
format='json'
)
assert resp.status_code == 200
resp = token_client.patch( resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, item.pk, addon.pk), '/api/v1/organizers/{}/events/{}/items/{}/addons/{}/'.format(organizer.slug, event.slug, item.pk, addon.pk),
{ {
+27 -2
View File
@@ -64,7 +64,7 @@ def order(event):
@pytest.fixture @pytest.fixture
def team(event): def team(event):
return event.organizer.teams.create(all_events=True, all_event_permissions=True) return event.organizer.teams.create(all_events=True, all_event_permissions=True, all_organizer_permissions=True)
@pytest.fixture @pytest.fixture
@@ -108,6 +108,17 @@ def test_notification_trigger_global_wildcard(event, order, user, django_capture
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
@pytest.mark.django_db
def test_notification_trigger_organizer(event, user, django_capture_on_commit_callbacks):
djmail.outbox = []
user.notification_settings.create(
method='mail', event=None, action_type='pretix.organizer.changed', enabled=True
)
with django_capture_on_commit_callbacks(execute=True):
event.organizer.log_action('pretix.organizer.changed', {})
assert len(djmail.outbox) == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_enabled_global_ignored_specific(event, order, user, django_capture_on_commit_callbacks): def test_notification_enabled_global_ignored_specific(event, order, user, django_capture_on_commit_callbacks):
djmail.outbox = [] djmail.outbox = []
@@ -134,7 +145,7 @@ def test_notification_ignore_same_user(event, order, user, django_capture_on_com
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_ignore_insufficient_permissions(event, order, user, team, django_capture_on_commit_callbacks): def test_notification_ignore_insufficient_event_permissions(event, order, user, team, django_capture_on_commit_callbacks):
djmail.outbox = [] djmail.outbox = []
team.all_event_permissions = False team.all_event_permissions = False
team.limit_event_permissions = {"event.vouchers:read": True} team.limit_event_permissions = {"event.vouchers:read": True}
@@ -146,4 +157,18 @@ def test_notification_ignore_insufficient_permissions(event, order, user, team,
order.log_action('pretix.event.order.paid', {}) order.log_action('pretix.event.order.paid', {})
assert len(djmail.outbox) == 0 assert len(djmail.outbox) == 0
@pytest.mark.django_db
def test_notification_ignore_insufficient_organizer_permissions(event, team, user, django_capture_on_commit_callbacks):
djmail.outbox = []
team.all_organizer_permissions = False
team.limit_organizer_permissions = {"organizer.giftcards:read": True}
team.save()
user.notification_settings.create(
method='mail', event=None, action_type='pretix.organizer.changed', enabled=True
)
with django_capture_on_commit_callbacks(execute=True):
event.organizer.log_action('pretix.organizer.changed', {})
assert len(djmail.outbox) == 0
# TODO: Test email content # TODO: Test email content
+6
View File
@@ -219,8 +219,14 @@ def test_specific_organizer_permission(event, user):
def test_organizer_permissions_multiple_teams(event, user): def test_organizer_permissions_multiple_teams(event, user):
team1 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.settings.general:write": True}) team1 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.settings.general:write": True})
team2 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.events:create": True}) team2 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.events:create": True})
assert set(event.organizer.get_users_with_permission('organizer.settings.general:write')) == set()
assert set(event.organizer.get_users_with_permission(None)) == set()
team1.members.add(user) team1.members.add(user)
assert set(event.organizer.get_users_with_permission('organizer.settings.general:write')) == {user}
assert set(event.organizer.get_users_with_permission(None)) == {user}
assert set(event.organizer.get_users_with_permission('organizer.events:create')) == set()
team2.members.add(user) team2.members.add(user)
assert set(event.organizer.get_users_with_permission('organizer.events:create')) == {user}
orga2 = Organizer.objects.create(slug='d2', name='d2') orga2 = Organizer.objects.create(slug='d2', name='d2')
team3 = Team.objects.create(organizer=orga2, limit_organizer_permissions={"organizer.teams:write": True}) team3 = Team.objects.create(organizer=orga2, limit_organizer_permissions={"organizer.teams:write": True})
team3.members.add(user) team3.members.add(user)
+31 -3
View File
@@ -26,11 +26,12 @@ from pretix.base.exporter import BaseExporter
from pretix.base.invoicing.transmission import ( from pretix.base.invoicing.transmission import (
TransmissionProvider, transmission_providers, TransmissionProvider, transmission_providers,
) )
from pretix.base.models import Invoice from pretix.base.models import Invoice, LogEntry
from pretix.base.notifications import Notification, NotificationType
from pretix.base.signals import ( from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters, register_data_exporters, register_multievent_data_exporters,
register_payment_providers, register_sales_channel_types, register_notification_types, register_payment_providers,
register_ticket_outputs, register_sales_channel_types, register_ticket_outputs,
) )
from pretix.presale.signals import html_head from pretix.presale.signals import html_head
@@ -144,3 +145,30 @@ class TestPeppolTransmissionProvider(TransmissionProvider):
def transmit(self, invoice): def transmit(self, invoice):
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED invoice.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
invoice.save() invoice.save()
class OrgLevelTestNotificationType(NotificationType):
required_permission = "organizer.settings.general:write" # does not make much sense, just for tests
is_event_level = False
action_type = "pretix.organizer.changed"
verbose_name = "Organizer changed"
def __init__(self, organizer):
super().__init__(organizer)
def build_notification(self, logentry: LogEntry):
n = Notification(
event=None,
organizer=logentry.organizer,
title="Organizer Changed",
url="https://example.com"
)
n.add_attribute('Organizer', logentry.organizer.name)
return n
@receiver(register_notification_types, dispatch_uid="testdummy_register_notification_types")
def testdummy_register_notification_types(sender, **kwargs):
return (
OrgLevelTestNotificationType(sender)
)