mirror of
https://github.com/pretix/pretix.git
synced 2026-08-04 09:47:50 +00:00
Compare commits
248
Commits
@@ -23,13 +23,13 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11"]
|
python-version: ["3.10", "3.11", "3.13"]
|
||||||
database: [sqlite, postgres]
|
database: [sqlite, postgres]
|
||||||
exclude:
|
exclude:
|
||||||
- database: sqlite
|
|
||||||
python-version: "3.9"
|
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.11"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
|
|||||||
@@ -208,20 +208,6 @@ Additionally, when creating a device through the user interface or API, a user c
|
|||||||
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
|
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
|
||||||
policies for official pretix apps like pretixSCAN and pretixPOS.
|
policies for official pretix apps like pretixSCAN and pretixPOS.
|
||||||
|
|
||||||
Removing a device
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
|
|
||||||
invalidate your API key. There is no way to reverse this operation.
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /api/v1/device/revoke HTTP/1.1
|
|
||||||
Host: pretix.eu
|
|
||||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
|
||||||
|
|
||||||
This can also be done by the user through the web interface.
|
|
||||||
|
|
||||||
Event selection
|
Event selection
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core:
|
|||||||
* ``pretix.event.added``
|
* ``pretix.event.added``
|
||||||
* ``pretix.event.changed``
|
* ``pretix.event.changed``
|
||||||
* ``pretix.event.deleted``
|
* ``pretix.event.deleted``
|
||||||
|
* ``pretix.giftcards.created``
|
||||||
|
* ``pretix.giftcards.modified``
|
||||||
|
* ``pretix.giftcards.transaction.*``
|
||||||
* ``pretix.voucher.added``
|
* ``pretix.voucher.added``
|
||||||
* ``pretix.voucher.changed``
|
* ``pretix.voucher.changed``
|
||||||
* ``pretix.voucher.deleted``
|
* ``pretix.voucher.deleted``
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
sphinx==9.1.*
|
sphinx==9.1.*
|
||||||
sphinx-rtd-theme~=3.1.0rc1
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinxcontrib-httpdomain~=1.8.1
|
sphinxcontrib-httpdomain~=2.0.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,7 +1,7 @@
|
|||||||
-e ../
|
-e ../
|
||||||
sphinx==9.1.*
|
sphinx==9.1.*
|
||||||
sphinx-rtd-theme~=3.1.0rc1
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinxcontrib-httpdomain~=1.8.1
|
sphinxcontrib-httpdomain~=2.0.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
|
||||||
|
|||||||
+17
-17
@@ -3,7 +3,7 @@ name = "pretix"
|
|||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = "Reinventing presales, one ticket at a time"
|
description = "Reinventing presales, one ticket at a time"
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||||
authors = [
|
authors = [
|
||||||
@@ -29,19 +29,19 @@ dependencies = [
|
|||||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||||
"babel",
|
"babel",
|
||||||
"BeautifulSoup4==4.14.*",
|
"BeautifulSoup4==4.14.*",
|
||||||
"bleach==6.2.*",
|
"bleach==6.3.*",
|
||||||
"celery==5.6.*",
|
"celery==5.6.*",
|
||||||
"chardet==5.2.*",
|
"chardet==5.2.*",
|
||||||
"cryptography>=44.0.0",
|
"cryptography>=44.0.0",
|
||||||
"css-inline==0.19.*",
|
"css-inline==0.20.*",
|
||||||
"defusedcsv>=1.1.0",
|
"defusedcsv>=1.1.0",
|
||||||
"dnspython==2.*",
|
"dnspython==2.*",
|
||||||
"Django[argon2]==4.2.*,>=4.2.26",
|
"Django[argon2]==4.2.*,>=4.2.26",
|
||||||
"django-bootstrap3==25.2",
|
"django-bootstrap3==26.1",
|
||||||
"django-compressor==4.5.1",
|
"django-compressor==4.6.0",
|
||||||
"django-countries==8.2.*",
|
"django-countries==8.2.*",
|
||||||
"django-filter==25.1",
|
"django-filter==25.1",
|
||||||
"django-formset-js-improved==0.5.0.4",
|
"django-formset-js-improved==0.5.0.5",
|
||||||
"django-formtools==2.5.1",
|
"django-formtools==2.5.1",
|
||||||
"django-hierarkey==2.0.*,>=2.0.1",
|
"django-hierarkey==2.0.*,>=2.0.1",
|
||||||
"django-hijack==3.7.*",
|
"django-hijack==3.7.*",
|
||||||
@@ -50,13 +50,13 @@ dependencies = [
|
|||||||
"django-localflavor==5.0",
|
"django-localflavor==5.0",
|
||||||
"django-markup",
|
"django-markup",
|
||||||
"django-oauth-toolkit==2.3.*",
|
"django-oauth-toolkit==2.3.*",
|
||||||
"django-otp==1.6.*",
|
"django-otp==1.7.*",
|
||||||
"django-phonenumber-field==8.3.*",
|
"django-phonenumber-field==8.4.*",
|
||||||
"django-redis==6.0.*",
|
"django-redis==6.0.*",
|
||||||
"django-scopes==2.0.*",
|
"django-scopes==2.0.*",
|
||||||
"django-statici18n==2.6.*",
|
"django-statici18n==2.6.*",
|
||||||
"djangorestframework==3.16.*",
|
"djangorestframework==3.16.*",
|
||||||
"dnspython==2.7.*",
|
"dnspython==2.8.*",
|
||||||
"drf_ujson2==1.7.*",
|
"drf_ujson2==1.7.*",
|
||||||
"geoip2==5.*",
|
"geoip2==5.*",
|
||||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||||
@@ -65,7 +65,7 @@ dependencies = [
|
|||||||
"kombu==5.6.*",
|
"kombu==5.6.*",
|
||||||
"libsass==0.23.*",
|
"libsass==0.23.*",
|
||||||
"lxml",
|
"lxml",
|
||||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||||
"mt-940==4.30.*",
|
"mt-940==4.30.*",
|
||||||
"oauthlib==3.3.*",
|
"oauthlib==3.3.*",
|
||||||
@@ -73,14 +73,14 @@ dependencies = [
|
|||||||
"packaging",
|
"packaging",
|
||||||
"paypalrestsdk==1.13.*",
|
"paypalrestsdk==1.13.*",
|
||||||
"paypal-checkout-serversdk==1.0.*",
|
"paypal-checkout-serversdk==1.0.*",
|
||||||
"PyJWT==2.10.*",
|
"PyJWT==2.11.*",
|
||||||
"phonenumberslite==9.0.*",
|
"phonenumberslite==9.0.*",
|
||||||
"Pillow==11.3.*",
|
"Pillow==12.1.*",
|
||||||
"pretix-plugin-build",
|
"pretix-plugin-build",
|
||||||
"protobuf==6.33.*",
|
"protobuf==6.33.*",
|
||||||
"psycopg2-binary",
|
"psycopg2-binary",
|
||||||
"pycountry",
|
"pycountry",
|
||||||
"pycparser==2.23",
|
"pycparser==3.0",
|
||||||
"pycryptodome==3.23.*",
|
"pycryptodome==3.23.*",
|
||||||
"pypdf==6.5.*",
|
"pypdf==6.5.*",
|
||||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||||
@@ -89,10 +89,10 @@ dependencies = [
|
|||||||
"pytz-deprecation-shim==0.1.*",
|
"pytz-deprecation-shim==0.1.*",
|
||||||
"pyuca",
|
"pyuca",
|
||||||
"qrcode==8.2",
|
"qrcode==8.2",
|
||||||
"redis==7.0.*",
|
"redis==7.1.*",
|
||||||
"reportlab==4.4.*",
|
"reportlab==4.4.*",
|
||||||
"requests==2.32.*",
|
"requests==2.32.*",
|
||||||
"sentry-sdk==2.48.*",
|
"sentry-sdk==2.52.*",
|
||||||
"sepaxml==2.7.*",
|
"sepaxml==2.7.*",
|
||||||
"stripe==7.9.*",
|
"stripe==7.9.*",
|
||||||
"text-unidecode==1.*",
|
"text-unidecode==1.*",
|
||||||
@@ -113,7 +113,7 @@ dev = [
|
|||||||
"fakeredis==2.33.*",
|
"fakeredis==2.33.*",
|
||||||
"flake8==7.3.*",
|
"flake8==7.3.*",
|
||||||
"freezegun",
|
"freezegun",
|
||||||
"isort==6.1.*",
|
"isort==7.0.*",
|
||||||
"pep8-naming==0.15.*",
|
"pep8-naming==0.15.*",
|
||||||
"potypo",
|
"potypo",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
@@ -123,7 +123,7 @@ dev = [
|
|||||||
"pytest-mock==3.15.*",
|
"pytest-mock==3.15.*",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist==3.8.*",
|
"pytest-xdist==3.8.*",
|
||||||
"pytest==8.4.*",
|
"pytest==9.0.*",
|
||||||
"responses",
|
"responses",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "2025.11.0.dev0"
|
__version__ = "2026.2.0.dev0"
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_reissue_after_modify',
|
'invoice_reissue_after_modify',
|
||||||
'invoice_include_free',
|
'invoice_include_free',
|
||||||
'invoice_generate',
|
'invoice_generate',
|
||||||
|
'invoice_generate_only_business',
|
||||||
'invoice_period',
|
'invoice_period',
|
||||||
'invoice_numbers_consecutive',
|
'invoice_numbers_consecutive',
|
||||||
'invoice_numbers_prefix',
|
'invoice_numbers_prefix',
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
|||||||
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
||||||
)
|
)
|
||||||
break # do not call else branch of for loop
|
break # do not call else branch of for loop
|
||||||
elif t.exclusive:
|
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||||
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||||
@@ -704,6 +704,16 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
|||||||
if 'answers.question' in self.context['expand']:
|
if 'answers.question' in self.context['expand']:
|
||||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||||
|
|
||||||
|
if 'addons' in self.context['expand']:
|
||||||
|
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||||
|
# for performance reasons
|
||||||
|
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
|
||||||
|
**self.context,
|
||||||
|
'expand': [v for v in self.context['expand'] if v != 'addons'],
|
||||||
|
'pdf_data': False,
|
||||||
|
})
|
||||||
|
self.fields['addons'] = subl
|
||||||
|
|
||||||
|
|
||||||
class OrderPaymentTypeField(serializers.Field):
|
class OrderPaymentTypeField(serializers.Field):
|
||||||
# TODO: Remove after pretix 2.2
|
# TODO: Remove after pretix 2.2
|
||||||
@@ -1601,7 +1611,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.sales_channel,
|
order.sales_channel,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
||||||
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
cp.addon_to, cp.is_bundled, pos._voucher_discount)
|
||||||
for cp in order_positions
|
for cp in order_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1733,6 +1743,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
rounding_mode = self.context["event"].settings.tax_rounding
|
rounding_mode = self.context["event"].settings.tax_rounding
|
||||||
changed = apply_rounding(
|
changed = apply_rounding(
|
||||||
rounding_mode,
|
rounding_mode,
|
||||||
|
ia,
|
||||||
self.context["event"].currency,
|
self.context["event"].currency,
|
||||||
[*pos_map.values(), *fees]
|
[*pos_map.values(), *fees]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from pretix.base.plugins import (
|
|||||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||||
PLUGIN_LEVEL_ORGANIZER,
|
PLUGIN_LEVEL_ORGANIZER,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.settings import validate_organizer_settings
|
from pretix.base.settings import validate_organizer_settings
|
||||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
@@ -363,24 +363,21 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _send_invite(self, instance):
|
def _send_invite(self, instance):
|
||||||
try:
|
mail(
|
||||||
mail(
|
instance.email,
|
||||||
instance.email,
|
_('pretix account invitation'),
|
||||||
_('pretix account invitation'),
|
'pretixcontrol/email/invitation.txt',
|
||||||
'pretixcontrol/email/invitation.txt',
|
{
|
||||||
{
|
'user': self,
|
||||||
'user': self,
|
'organizer': self.context['organizer'].name,
|
||||||
'organizer': self.context['organizer'].name,
|
'team': instance.team.name,
|
||||||
'team': instance.team.name,
|
'url': build_global_uri('control:auth.invite', kwargs={
|
||||||
'url': build_global_uri('control:auth.invite', kwargs={
|
'token': instance.token
|
||||||
'token': instance.token
|
})
|
||||||
})
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
locale=get_language_without_region() # TODO: expose?
|
||||||
locale=get_language_without_region() # TODO: expose?
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if 'email' in validated_data:
|
if 'email' in validated_data:
|
||||||
@@ -443,6 +440,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'customer_accounts',
|
'customer_accounts',
|
||||||
'customer_accounts_native',
|
'customer_accounts_native',
|
||||||
'customer_accounts_link_by_email',
|
'customer_accounts_link_by_email',
|
||||||
|
'customer_accounts_require_login_for_order_access',
|
||||||
'invoice_regenerate_allowed',
|
'invoice_regenerate_allowed',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
|
|||||||
@@ -381,15 +381,21 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
|
|
||||||
qs = qs.filter(reduce(operator.or_, lists_qs))
|
qs = qs.filter(reduce(operator.or_, lists_qs))
|
||||||
|
|
||||||
|
prefetch_related = [
|
||||||
|
Prefetch(
|
||||||
|
lookup='checkins',
|
||||||
|
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
||||||
|
),
|
||||||
|
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||||
|
'answers', 'answers__options', 'answers__question',
|
||||||
|
]
|
||||||
|
select_related = [
|
||||||
|
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
|
||||||
|
]
|
||||||
|
|
||||||
if pdf_data:
|
if pdf_data:
|
||||||
qs = qs.prefetch_related(
|
qs = qs.prefetch_related(
|
||||||
Prefetch(
|
# Don't add to list, we don't want to propagate to addons
|
||||||
lookup='checkins',
|
|
||||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
|
||||||
),
|
|
||||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
|
||||||
'answers', 'answers__options', 'answers__question',
|
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
|
||||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
'event',
|
'event',
|
||||||
@@ -404,32 +410,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
).select_related(
|
|
||||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
qs = qs.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
lookup='checkins',
|
|
||||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
|
||||||
),
|
|
||||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
|
||||||
'answers', 'answers__options', 'answers__question',
|
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
|
||||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
|
||||||
|
|
||||||
if expand and 'subevent' in expand:
|
if expand and 'subevent' in expand:
|
||||||
qs = qs.prefetch_related(
|
prefetch_related += [
|
||||||
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
||||||
'subevent__seat_category_mappings', 'subevent__meta_values'
|
'subevent__seat_category_mappings', 'subevent__meta_values'
|
||||||
)
|
]
|
||||||
|
|
||||||
if expand and 'item' in expand:
|
if expand and 'item' in expand:
|
||||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
|
prefetch_related += [
|
||||||
'item__variations').select_related('item__tax_rule')
|
'item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||||
|
'item__variations',
|
||||||
|
]
|
||||||
|
select_related.append('item__tax_rule')
|
||||||
|
|
||||||
if expand and 'variation' in expand:
|
if expand and 'variation' in expand:
|
||||||
qs = qs.prefetch_related('variation', 'variation__meta_values')
|
prefetch_related += [
|
||||||
|
'variation', 'variation__meta_values',
|
||||||
|
]
|
||||||
|
|
||||||
|
if expand and 'addons' in expand:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||||
|
]
|
||||||
|
|
||||||
|
if pdf_data:
|
||||||
|
select_related.remove("order") # Don't need it twice on this queryset
|
||||||
|
|
||||||
|
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -966,6 +979,7 @@ class CheckinRPCSearchView(ListAPIView):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||||
|
ctx['organizer'] = self.request.organizer
|
||||||
ctx['pdf_data'] = False
|
ctx['pdf_data'] = False
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
||||||
'variations__meta_values', 'variations__meta_values__property',
|
'variations__meta_values', 'variations__meta_values__property',
|
||||||
'require_membership_types', 'variations__require_membership_types',
|
'require_membership_types', 'variations__require_membership_types',
|
||||||
'limit_sales_channels', 'variations__limit_sales_channels',
|
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ from pretix.base.services.invoices import (
|
|||||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||||
regenerate_invoice, transmit_invoice,
|
regenerate_invoice, transmit_invoice,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderChangeManager, OrderError, _order_placed_email,
|
OrderChangeManager, OrderError, _order_placed_email,
|
||||||
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
||||||
@@ -439,8 +438,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
return Response(
|
return Response(
|
||||||
@@ -634,10 +631,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
if not order.email:
|
if not order.email:
|
||||||
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
try:
|
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||||
order.resend_link(user=self.request.user, auth=self.request.auth)
|
|
||||||
except SendMailException:
|
|
||||||
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
status=status.HTTP_204_NO_CONTENT
|
status=status.HTTP_204_NO_CONTENT
|
||||||
@@ -1616,8 +1610,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
pass
|
pass
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
||||||
|
|
||||||
@@ -1655,8 +1647,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
@@ -2031,7 +2021,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
else:
|
else:
|
||||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
||||||
c = generate_cancellation(inv)
|
c = generate_cancellation(inv)
|
||||||
if inv.order.status != Order.STATUS_CANCELED:
|
if invoice_qualified(order):
|
||||||
inv = generate_invoice(order)
|
inv = generate_invoice(order)
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
|
|||||||
@@ -249,12 +249,17 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
value = serializer.validated_data.pop('value')
|
value = serializer.validated_data.pop('value')
|
||||||
inst = serializer.save(issuer=self.request.organizer)
|
inst = serializer.save(issuer=self.request.organizer)
|
||||||
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.created',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
)
|
||||||
|
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
||||||
|
inst.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
@@ -269,7 +274,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||||
testmode=serializer.instance.testmode)
|
testmode=serializer.instance.testmode)
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.modified',
|
action='pretix.giftcards.modified',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=self.request.data,
|
data=self.request.data,
|
||||||
@@ -282,10 +287,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
diff = value - old_value
|
diff = value - old_value
|
||||||
inst.transactions.create(value=diff, acceptor=self.request.organizer)
|
inst.transactions.create(value=diff, acceptor=self.request.organizer)
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': diff}
|
data={'value': diff, 'acceptor_id': self.request.organizer.id}
|
||||||
)
|
)
|
||||||
|
|
||||||
return inst
|
return inst
|
||||||
@@ -309,10 +314,14 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
}, status=status.HTTP_409_CONFLICT)
|
}, status=status.HTTP_409_CONFLICT)
|
||||||
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
|
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
|
||||||
gc.log_action(
|
gc.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': value, 'text': text}
|
data={
|
||||||
|
'value': value,
|
||||||
|
'text': text,
|
||||||
|
'acceptor_id': self.request.organizer.id
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
|
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,35 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
|
||||||
|
def build_payload(self, logentry: LogEntry):
|
||||||
|
giftcard = logentry.content_object
|
||||||
|
if not giftcard:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'notification_id': logentry.pk,
|
||||||
|
'issuer_id': logentry.organizer_id,
|
||||||
|
'giftcard': giftcard.pk,
|
||||||
|
'action': logentry.action_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
|
||||||
|
def build_payload(self, logentry: LogEntry):
|
||||||
|
giftcard = logentry.content_object
|
||||||
|
if not giftcard:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'notification_id': logentry.pk,
|
||||||
|
'issuer_id': logentry.organizer_id,
|
||||||
|
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
|
||||||
|
'giftcard': giftcard.pk,
|
||||||
|
'action': logentry.action_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
||||||
|
|
||||||
def build_payload(self, logentry: LogEntry):
|
def build_payload(self, logentry: LogEntry):
|
||||||
@@ -433,6 +462,18 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
'pretix.customer.anonymized',
|
'pretix.customer.anonymized',
|
||||||
_('Customer account anonymized'),
|
_('Customer account anonymized'),
|
||||||
),
|
),
|
||||||
|
ParametrizedGiftcardWebhookEvent(
|
||||||
|
'pretix.giftcards.created',
|
||||||
|
_('Gift card added'),
|
||||||
|
),
|
||||||
|
ParametrizedGiftcardWebhookEvent(
|
||||||
|
'pretix.giftcards.modified',
|
||||||
|
_('Gift card modified'),
|
||||||
|
),
|
||||||
|
ParametrizedGiftcardTransactionWebhookEvent(
|
||||||
|
'pretix.giftcards.transaction.*',
|
||||||
|
_('Gift card used in transcation'),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
|
|||||||
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
||||||
markdown_compile_email, truelink_callback,
|
markdown_compile_email, truelink_callback,
|
||||||
)
|
)
|
||||||
from pretix.helpers.format import SafeFormatter, format_map
|
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
|
||||||
|
|
||||||
from pretix.base.services.placeholders import ( # noqa
|
from pretix.base.services.placeholders import ( # noqa
|
||||||
get_available_placeholders, PlaceholderContext
|
get_available_placeholders, PlaceholderContext
|
||||||
@@ -141,6 +141,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
return markdown_compile_email(plaintext, context=context)
|
return markdown_compile_email(plaintext, context=context)
|
||||||
|
|
||||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||||
|
apply_format_map = not isinstance(plain_body, FormattedString)
|
||||||
body_md = self.compile_markdown(plain_body, context)
|
body_md = self.compile_markdown(plain_body, context)
|
||||||
if context:
|
if context:
|
||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
@@ -149,12 +150,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
body_md = format_map(
|
if apply_format_map:
|
||||||
body_md,
|
body_md = format_map(
|
||||||
context=context,
|
body_md,
|
||||||
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
context=context,
|
||||||
linkifier=linker
|
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||||
)
|
linkifier=linker
|
||||||
|
)
|
||||||
htmlctx = {
|
htmlctx = {
|
||||||
'site': settings.PRETIX_INSTANCE_NAME,
|
'site': settings.PRETIX_INSTANCE_NAME,
|
||||||
'site_url': settings.SITE_URL,
|
'site_url': settings.SITE_URL,
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
|
||||||
Q, Subquery, Sum, When,
|
OuterRef, Q, Subquery, Sum, When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -144,6 +144,18 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
d = OrderedDict(d)
|
d = OrderedDict(d)
|
||||||
if not self.is_multievent and not self.event.has_subevents:
|
if not self.is_multievent and not self.event.has_subevents:
|
||||||
del d['event_date_range']
|
del d['event_date_range']
|
||||||
|
if not self.is_multievent:
|
||||||
|
d["items"] = forms.ModelMultipleChoiceField(
|
||||||
|
label=_("Products"),
|
||||||
|
queryset=self.event.items.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple(
|
||||||
|
attrs={"class": "scrolling-multiple-choice"}
|
||||||
|
),
|
||||||
|
help_text=_("If none are selected, all products are included. Orders are included if they contain "
|
||||||
|
"at least one position of this product. The order totals etc. still include all products "
|
||||||
|
"contained in the order."),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _get_all_payment_methods(self, qs):
|
def _get_all_payment_methods(self, qs):
|
||||||
@@ -249,6 +261,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
pcnt=Subquery(s, output_field=IntegerField())
|
pcnt=Subquery(s, output_field=IntegerField())
|
||||||
).select_related('invoice_address', 'customer')
|
).select_related('invoice_address', 'customer')
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
Exists(OrderPosition.all.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
item__in=form_data["items"]
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='')
|
qs = self._date_filter(qs, form_data, rel='')
|
||||||
|
|
||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
@@ -364,7 +384,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.custom_field,
|
order.invoice_address.custom_field,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
@@ -440,6 +460,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
Exists(OrderPosition.all.filter(
|
||||||
|
order=OuterRef('order'),
|
||||||
|
item__in=form_data["items"]
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='order__')
|
qs = self._date_filter(qs, form_data, rel='order__')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -515,7 +543,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -535,6 +563,11 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
item__in=form_data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='order__')
|
qs = self._date_filter(qs, form_data, rel='order__')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -617,6 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
_('Country'),
|
_('Country'),
|
||||||
pgettext('address', 'State'),
|
pgettext('address', 'State'),
|
||||||
_('Voucher'),
|
_('Voucher'),
|
||||||
|
_('Voucher budget usage'),
|
||||||
_('Pseudonymization ID'),
|
_('Pseudonymization ID'),
|
||||||
_('Ticket secret'),
|
_('Ticket secret'),
|
||||||
_('Seat ID'),
|
_('Seat ID'),
|
||||||
@@ -732,8 +766,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
op.zipcode or '',
|
op.zipcode or '',
|
||||||
op.city or '',
|
op.city or '',
|
||||||
op.country if op.country else '',
|
op.country if op.country else '',
|
||||||
op.state or '',
|
op.state_for_address or '',
|
||||||
op.voucher.code if op.voucher else '',
|
op.voucher.code if op.voucher else '',
|
||||||
|
op.voucher_budget_use if op.voucher_budget_use else '',
|
||||||
op.pseudonymization_id,
|
op.pseudonymization_id,
|
||||||
op.secret,
|
op.secret,
|
||||||
]
|
]
|
||||||
@@ -797,7 +832,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
|||||||
@@ -890,18 +890,18 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if not help_text:
|
if not help_text:
|
||||||
if q.valid_date_min and q.valid_date_max:
|
if q.valid_date_min and q.valid_date_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date between {min} and {max}.',
|
_('Please enter a date between {min} and {max}.'),
|
||||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_date_min:
|
elif q.valid_date_min:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date no earlier than {min}.',
|
_('Please enter a date no earlier than {min}.'),
|
||||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_date_max:
|
elif q.valid_date_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date no later than {max}.',
|
_('Please enter a date no later than {max}.'),
|
||||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
if initial and initial.answer:
|
if initial and initial.answer:
|
||||||
@@ -939,18 +939,18 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if not help_text:
|
if not help_text:
|
||||||
if q.valid_datetime_min and q.valid_datetime_max:
|
if q.valid_datetime_min and q.valid_datetime_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time between {min} and {max}.',
|
_('Please enter a date and time between {min} and {max}.'),
|
||||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_datetime_min:
|
elif q.valid_datetime_min:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time no earlier than {min}.',
|
_('Please enter a date and time no earlier than {min}.'),
|
||||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_datetime_max:
|
elif q.valid_datetime_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time no later than {max}.',
|
_('Please enter a date and time no later than {max}.'),
|
||||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1417,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.instance.transmission_type = transmission_type.identifier
|
self.instance.transmission_type = transmission_type.identifier
|
||||||
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
|
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
|
||||||
elif transmission_type.exclusive:
|
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
|
||||||
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||||
|
|||||||
+50
-12
@@ -34,14 +34,13 @@
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from asgiref.local import Local
|
||||||
from babel import localedata
|
from babel import localedata
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.formats import date_format, number_format
|
from django.utils.formats import date_format, number_format
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
|
|
||||||
from pretix.base.templatetags.money import money_filter
|
|
||||||
|
|
||||||
from i18nfield.fields import ( # noqa
|
from i18nfield.fields import ( # noqa
|
||||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||||
)
|
)
|
||||||
@@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa
|
|||||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||||
|
|
||||||
|
|
||||||
|
_active_region = Local()
|
||||||
|
|
||||||
|
|
||||||
class LazyDate:
|
class LazyDate:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -86,6 +88,8 @@ class LazyCurrencyNumber:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
from pretix.base.templatetags.money import money_filter
|
||||||
|
|
||||||
return money_filter(self.value, self.currency)
|
return money_filter(self.value, self.currency)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
|||||||
|
|
||||||
|
|
||||||
def get_babel_locale():
|
def get_babel_locale():
|
||||||
babel_locale = 'en'
|
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
# Also, this returns best-effort region information for number formatting etc
|
||||||
if translation.get_language():
|
current_language = translation.get_language()
|
||||||
if localedata.exists(translation.get_language()):
|
current_region = getattr(_active_region, "value", None)
|
||||||
babel_locale = translation.get_language()
|
|
||||||
elif localedata.exists(translation.get_language()[:2]):
|
# Babel only accepts locales that exist on the system. We try combinations in the following order:
|
||||||
babel_locale = translation.get_language()[:2]
|
# language-languageversion-region
|
||||||
return babel_locale
|
# language-region
|
||||||
|
# language-languageversion
|
||||||
|
# language
|
||||||
|
# fallback to system default
|
||||||
|
# fallback to english
|
||||||
|
|
||||||
|
try_locales = []
|
||||||
|
if current_language:
|
||||||
|
if "-" in current_language:
|
||||||
|
lng_parts = current_language.split("-")
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}")
|
||||||
|
else:
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{current_language}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{current_language}")
|
||||||
|
|
||||||
|
try_locales.append(settings.LANGUAGE_CODE)
|
||||||
|
|
||||||
|
for locale in try_locales:
|
||||||
|
if localedata.exists(locale):
|
||||||
|
return localedata.normalize_locale(locale)
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
|
||||||
def get_language_without_region(lng=None):
|
def get_language_without_region(lng=None):
|
||||||
@@ -132,6 +163,10 @@ def get_language_without_region(lng=None):
|
|||||||
return lng
|
return lng
|
||||||
|
|
||||||
|
|
||||||
|
def set_region(region):
|
||||||
|
_active_region.value = region
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def language(lng, region=None):
|
def language(lng, region=None):
|
||||||
"""
|
"""
|
||||||
@@ -143,15 +178,18 @@ def language(lng, region=None):
|
|||||||
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
||||||
attribute will be ignored.
|
attribute will be ignored.
|
||||||
"""
|
"""
|
||||||
_lng = translation.get_language()
|
lng_before = translation.get_language()
|
||||||
|
region_before = getattr(_active_region, "value", None)
|
||||||
lng = lng or settings.LANGUAGE_CODE
|
lng = lng or settings.LANGUAGE_CODE
|
||||||
if '-' not in lng and region:
|
if '-' not in lng and region:
|
||||||
lng += '-' + region.lower()
|
lng += '-' + region.lower()
|
||||||
translation.activate(lng)
|
translation.activate(lng)
|
||||||
|
_active_region.value = region
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
translation.activate(_lng)
|
translation.activate(lng_before)
|
||||||
|
_active_region.value = region_before
|
||||||
|
|
||||||
|
|
||||||
class LazyLocaleException(Exception):
|
class LazyLocaleException(Exception):
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from pretix.base.invoicing.transmission import (
|
|||||||
transmission_types,
|
transmission_types,
|
||||||
)
|
)
|
||||||
from pretix.base.models import Invoice, InvoiceAddress
|
from pretix.base.models import Invoice, InvoiceAddress
|
||||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
from pretix.base.services.mail import mail, render_mail
|
||||||
from pretix.helpers.format import format_map
|
from pretix.helpers.format import format_map
|
||||||
|
|
||||||
|
|
||||||
@@ -133,41 +133,37 @@ class EmailTransmissionProvider(TransmissionProvider):
|
|||||||
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
|
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
|
||||||
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
||||||
|
|
||||||
try:
|
# Do not set to completed because that is done by the email sending task
|
||||||
# Do not set to completed because that is done by the email sending task
|
subject = format_map(subject, context)
|
||||||
subject = format_map(subject, context)
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
mail(
|
||||||
mail(
|
[recipient],
|
||||||
[recipient],
|
subject,
|
||||||
subject,
|
template,
|
||||||
template,
|
context=context,
|
||||||
context=context,
|
event=invoice.order.event,
|
||||||
event=invoice.order.event,
|
locale=invoice.order.locale,
|
||||||
locale=invoice.order.locale,
|
order=invoice.order,
|
||||||
order=invoice.order,
|
invoices=[invoice],
|
||||||
invoices=[invoice],
|
attach_tickets=False,
|
||||||
attach_tickets=False,
|
auto_email=True,
|
||||||
auto_email=True,
|
attach_ical=False,
|
||||||
attach_ical=False,
|
plain_text_only=True,
|
||||||
plain_text_only=True,
|
no_order_links=True,
|
||||||
no_order_links=True,
|
)
|
||||||
)
|
invoice.order.log_action(
|
||||||
except SendMailException:
|
'pretix.event.order.email.invoice',
|
||||||
raise
|
user=None,
|
||||||
else:
|
auth=None,
|
||||||
invoice.order.log_action(
|
data={
|
||||||
'pretix.event.order.email.invoice',
|
'subject': subject,
|
||||||
user=None,
|
'message': email_content,
|
||||||
auth=None,
|
'position': None,
|
||||||
data={
|
'recipient': recipient,
|
||||||
'subject': subject,
|
'invoices': [invoice.pk],
|
||||||
'message': email_content,
|
'attach_tickets': False,
|
||||||
'position': None,
|
'attach_ical': False,
|
||||||
'recipient': recipient,
|
'attach_other_files': [],
|
||||||
'invoices': [invoice.pk],
|
'attach_cached_files': [],
|
||||||
'attach_tickets': False,
|
}
|
||||||
'attach_ical': False,
|
)
|
||||||
'attach_other_files': [],
|
|
||||||
'attach_cached_files': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ class ItalianSdITransmissionType(TransmissionType):
|
|||||||
identifier = "it_sdi"
|
identifier = "it_sdi"
|
||||||
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
||||||
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
||||||
exclusive = True
|
|
||||||
enforce_transmission = True
|
enforce_transmission = True
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
return str(country) == "IT"
|
||||||
|
|
||||||
def is_available(self, event, country: Country, is_business: bool):
|
def is_available(self, event, country: Country, is_business: bool):
|
||||||
return str(country) == "IT" and super().is_available(event, country, is_business)
|
return str(country) == "IT" and super().is_available(event, country, is_business)
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class PeppolIdValidator:
|
|||||||
"0020": "[0-9]{9}",
|
"0020": "[0-9]{9}",
|
||||||
"0201": "[0-9a-zA-Z]{6}",
|
"0201": "[0-9a-zA-Z]{6}",
|
||||||
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
||||||
"0208": "0[0-9]{9}",
|
"0208": "[01][0-9]{9}",
|
||||||
"0209": ".*",
|
"0209": ".*",
|
||||||
"0210": "[A-Z0-9]+",
|
"0210": "[A-Z0-9]+",
|
||||||
"0211": "IT[0-9]{11}",
|
"0211": "IT[0-9]{11}",
|
||||||
@@ -179,6 +179,12 @@ class PeppolTransmissionType(TransmissionType):
|
|||||||
def is_available(self, event, country: Country, is_business: bool):
|
def is_available(self, event, country: Country, is_business: bool):
|
||||||
return is_business and super().is_available(event, country, is_business)
|
return is_business and super().is_available(event, country, is_business)
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
|
||||||
|
# Peppol is required to be used for intra-Belgian B2B invoices
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def invoice_address_form_fields(self) -> dict:
|
def invoice_address_form_fields(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#
|
#
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_countries.fields import Country
|
from django_countries.fields import Country
|
||||||
|
|
||||||
from pretix.base.models import Invoice, InvoiceAddress
|
from pretix.base.models import Invoice, InvoiceAddress
|
||||||
@@ -58,15 +59,6 @@ class TransmissionType:
|
|||||||
"""
|
"""
|
||||||
return 100
|
return 100
|
||||||
|
|
||||||
@property
|
|
||||||
def exclusive(self) -> bool:
|
|
||||||
"""
|
|
||||||
If a transmission type is exclusive, no other type can be chosen if this type is
|
|
||||||
available. Use e.g. if a certain transmission type is legally required in a certain
|
|
||||||
jurisdiction.
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enforce_transmission(self) -> bool:
|
def enforce_transmission(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -82,6 +74,15 @@ class TransmissionType:
|
|||||||
for provider, _ in providers
|
for provider, _ in providers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
"""
|
||||||
|
If a transmission type is exclusive, no other type can be chosen if this type is
|
||||||
|
available. Use e.g. if a certain transmission type is legally required in a certain
|
||||||
|
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
|
||||||
|
the type is not available.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
@@ -106,6 +107,22 @@ class TransmissionType:
|
|||||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||||
return transmission_info
|
return transmission_info
|
||||||
|
|
||||||
|
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
|
||||||
|
form_data = self.transmission_info_to_form_data(transmission_info)
|
||||||
|
data = []
|
||||||
|
visible_field_keys = self.invoice_address_form_fields_visible(country, is_business)
|
||||||
|
for k, f in self.invoice_address_form_fields.items():
|
||||||
|
if k not in visible_field_keys:
|
||||||
|
continue
|
||||||
|
v = form_data.get(k)
|
||||||
|
if v is True:
|
||||||
|
v = _("Yes")
|
||||||
|
elif v is False:
|
||||||
|
v = _("No")
|
||||||
|
if v:
|
||||||
|
data.append((f.label, v))
|
||||||
|
return data
|
||||||
|
|
||||||
def pdf_watermark(self) -> Optional[str]:
|
def pdf_watermark(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Return a watermark that should be rendered across the PDF file.
|
Return a watermark that should be rendered across the PDF file.
|
||||||
|
|||||||
@@ -294,14 +294,28 @@ def metric_values():
|
|||||||
channel = app.broker_connection().channel()
|
channel = app.broker_connection().channel()
|
||||||
if hasattr(channel, 'client') and channel.client is not None:
|
if hasattr(channel, 'client') and channel.client is not None:
|
||||||
client = channel.client
|
client = channel.client
|
||||||
|
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
|
||||||
|
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
|
||||||
|
|
||||||
for q in settings.CELERY_TASK_QUEUES:
|
for q in settings.CELERY_TASK_QUEUES:
|
||||||
llen = client.llen(q.name)
|
queue_lengths = []
|
||||||
lfirst = client.lindex(q.name, -1)
|
queue_delays = []
|
||||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
for prio in priority_steps:
|
||||||
if lfirst:
|
if prio:
|
||||||
ldata = json.loads(lfirst)
|
qname = f"{q.name}{sep}{prio}"
|
||||||
dt = time.time() - ldata.get('created', 0)
|
else:
|
||||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
qname = q.name
|
||||||
|
queue_length = client.llen(qname)
|
||||||
|
queue_lengths.append(queue_length)
|
||||||
|
oldest_queue_item = client.lindex(qname, -1)
|
||||||
|
if oldest_queue_item:
|
||||||
|
ldata = json.loads(oldest_queue_item)
|
||||||
|
oldest_item_age = time.time() - ldata.get('created', 0)
|
||||||
|
queue_delays.append(oldest_item_age)
|
||||||
|
|
||||||
|
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = sum(queue_lengths)
|
||||||
|
if queue_delays:
|
||||||
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = max(queue_delays)
|
||||||
else:
|
else:
|
||||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
|
|||||||
parse_accept_lang_header,
|
parse_accept_lang_header,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pretix.base.i18n import get_language_without_region
|
from pretix.base.i18n import get_language_without_region, set_region
|
||||||
from pretix.base.settings import global_settings_object
|
from pretix.base.settings import global_settings_object
|
||||||
from pretix.multidomain.urlreverse import (
|
from pretix.multidomain.urlreverse import (
|
||||||
get_event_domain, get_organizer_domain,
|
get_event_domain, get_organizer_domain,
|
||||||
@@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
)
|
)
|
||||||
if '-' not in language and settings_holder.settings.region:
|
if '-' not in language and settings_holder.settings.region:
|
||||||
language += '-' + settings_holder.settings.region
|
language += '-' + settings_holder.settings.region
|
||||||
|
if settings_holder.settings.region:
|
||||||
|
set_region(settings_holder.settings.region)
|
||||||
else:
|
else:
|
||||||
gs = global_settings_object(request)
|
gs = global_settings_object(request)
|
||||||
if '-' not in language and gs.settings.region:
|
if '-' not in language and gs.settings.region:
|
||||||
language += '-' + gs.settings.region
|
language += '-' + gs.settings.region
|
||||||
|
if gs.settings.region:
|
||||||
|
set_region(gs.settings.region)
|
||||||
|
|
||||||
translation.activate(language)
|
translation.activate(language)
|
||||||
request.LANGUAGE_CODE = get_language_without_region()
|
request.LANGUAGE_CODE = get_language_without_region()
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Generated by Django 4.2.26 on 2026-01-22 13:44
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.mail
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0296_invoice_invoice_from_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="OutgoingMail",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
|
||||||
|
("status", models.CharField(default="queued", max_length=200)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("sent", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("inflight_since", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("retry_after", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("error", models.TextField(null=True)),
|
||||||
|
("error_detail", models.TextField(null=True)),
|
||||||
|
("sensitive", models.BooleanField(default=False)),
|
||||||
|
("subject", models.TextField()),
|
||||||
|
("body_plain", models.TextField()),
|
||||||
|
("body_html", models.TextField(null=True)),
|
||||||
|
("sender", models.CharField(max_length=500)),
|
||||||
|
("headers", models.JSONField(default=dict)),
|
||||||
|
("to", models.JSONField(default=list)),
|
||||||
|
("cc", models.JSONField(default=list)),
|
||||||
|
("bcc", models.JSONField(default=list)),
|
||||||
|
("recipient_count", models.IntegerField()),
|
||||||
|
("should_attach_tickets", models.BooleanField(default=False)),
|
||||||
|
("should_attach_ical", models.BooleanField(default=False)),
|
||||||
|
("should_attach_other_files", models.JSONField(default=list)),
|
||||||
|
("actual_attachments", models.JSONField(default=list)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"orderposition",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.orderposition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organizer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.organizer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"should_attach_cached_files",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="outgoing_mails", to="pretixbase.cachedfile"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"should_attach_invoices",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="outgoing_mails", to="pretixbase.invoice"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("-created",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -41,6 +41,7 @@ from .items import (
|
|||||||
itempicture_upload_to,
|
itempicture_upload_to,
|
||||||
)
|
)
|
||||||
from .log import LogEntry
|
from .log import LogEntry
|
||||||
|
from .mail import OutgoingMail
|
||||||
from .media import ReusableMedium
|
from .media import ReusableMedium
|
||||||
from .memberships import Membership, MembershipType
|
from .memberships import Membership, MembershipType
|
||||||
from .notifications import NotificationSetting
|
from .notifications import NotificationSetting
|
||||||
|
|||||||
@@ -334,27 +334,24 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
return self.email
|
return self.email
|
||||||
|
|
||||||
def send_security_notice(self, messages, email=None):
|
def send_security_notice(self, messages, email=None):
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
try:
|
with language(self.locale):
|
||||||
with language(self.locale):
|
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
||||||
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
|
||||||
|
|
||||||
mail(
|
mail(
|
||||||
email or self.email,
|
email or self.email,
|
||||||
_('Account information changed'),
|
_('Account information changed'),
|
||||||
'pretixcontrol/email/security_notice.txt',
|
'pretixcontrol/email/security_notice.txt',
|
||||||
{
|
{
|
||||||
'user': self,
|
'user': self,
|
||||||
'messages': msg,
|
'messages': msg,
|
||||||
'url': build_absolute_uri('control:user.settings')
|
'url': build_absolute_uri('control:user.settings')
|
||||||
},
|
},
|
||||||
event=None,
|
event=None,
|
||||||
user=self,
|
user=self,
|
||||||
locale=self.locale
|
locale=self.locale
|
||||||
)
|
)
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|
||||||
def send_confirmation_code(self, session, reason, email=None, state=None):
|
def send_confirmation_code(self, session, reason, email=None, state=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ class LoggingMixin:
|
|||||||
organizer_id = self.event.organizer_id
|
organizer_id = self.event.organizer_id
|
||||||
elif hasattr(self, 'organizer_id'):
|
elif hasattr(self, 'organizer_id'):
|
||||||
organizer_id = self.organizer_id
|
organizer_id = self.organizer_id
|
||||||
|
elif hasattr(self, 'issuer_id'):
|
||||||
|
organizer_id = self.issuer_id
|
||||||
|
|
||||||
if user and not user.is_authenticated:
|
if user and not user.is_authenticated:
|
||||||
user = None
|
user = None
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from i18nfield.fields import I18nCharField
|
|||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.fields import MultiStringField
|
from pretix.base.models.fields import MultiStringField
|
||||||
from pretix.base.models.giftcards import GiftCardTransaction
|
from pretix.base.models.giftcards import GiftCardTransaction
|
||||||
@@ -164,6 +165,28 @@ class Customer(LoggedModel):
|
|||||||
self.attendee_profiles.all().delete()
|
self.attendee_profiles.all().delete()
|
||||||
self.invoice_addresses.all().delete()
|
self.invoice_addresses.all().delete()
|
||||||
|
|
||||||
|
def send_security_notice(self, message, email=None):
|
||||||
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
|
try:
|
||||||
|
with language(self.locale):
|
||||||
|
mail(
|
||||||
|
email or self.email,
|
||||||
|
self.organizer.settings.mail_subject_customer_security_notice,
|
||||||
|
self.organizer.settings.mail_text_customer_security_notice,
|
||||||
|
{
|
||||||
|
**self.get_email_context(),
|
||||||
|
'message': str(message),
|
||||||
|
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
|
||||||
|
},
|
||||||
|
customer=self,
|
||||||
|
organizer=self.organizer,
|
||||||
|
locale=self.locale
|
||||||
|
)
|
||||||
|
except SendMailException:
|
||||||
|
pass # Already logged
|
||||||
|
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def assign_identifier(self):
|
def assign_identifier(self):
|
||||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
||||||
@@ -293,6 +316,7 @@ class Customer(LoggedModel):
|
|||||||
locale=self.locale,
|
locale=self.locale,
|
||||||
customer=self,
|
customer=self,
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
|
sensitive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def usable_gift_cards(self, used_cards=[]):
|
def usable_gift_cards(self, used_cards=[]):
|
||||||
@@ -349,7 +373,7 @@ class AttendeeProfile(models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
|
|
||||||
PositionInfo = namedtuple('PositionInfo',
|
PositionInfo = namedtuple('PositionInfo',
|
||||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
|
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
||||||
'voucher_discount'])
|
'voucher_discount'])
|
||||||
|
|
||||||
|
|
||||||
@@ -279,6 +279,42 @@ class Discount(LoggedModel):
|
|||||||
for idx in condition_idx_group:
|
for idx in condition_idx_group:
|
||||||
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
||||||
|
|
||||||
|
def _addon_idx(self, positions, idx):
|
||||||
|
"""
|
||||||
|
If we have the following cart:
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
|
||||||
|
And we have a discount rule that grants "every 10th product is free", people tend to expect
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 9x Addon product 5€
|
||||||
|
- 1x Addon product free
|
||||||
|
- Main product
|
||||||
|
- 9x Addon product 5€
|
||||||
|
- 1x Addon product free
|
||||||
|
|
||||||
|
And get confused if they get
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 8x Addon product 5€
|
||||||
|
- 2x Addon product free
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
|
||||||
|
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
|
||||||
|
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
|
||||||
|
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
|
||||||
|
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
|
||||||
|
the weird case less likely.
|
||||||
|
"""
|
||||||
|
if not positions[idx].addon_to:
|
||||||
|
return 0
|
||||||
|
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
|
||||||
|
|
||||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||||
if len(condition_idx_group) < self.condition_min_count:
|
if len(condition_idx_group) < self.condition_min_count:
|
||||||
return
|
return
|
||||||
@@ -288,8 +324,8 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
if self.benefit_only_apply_to_cheapest_n_matches:
|
if self.benefit_only_apply_to_cheapest_n_matches:
|
||||||
# sort by line_price
|
# sort by line_price
|
||||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
|
|
||||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||||
# want to match multiples of 3
|
# want to match multiples of 3
|
||||||
@@ -434,7 +470,7 @@ class Discount(LoggedModel):
|
|||||||
for idx, p in positions.items():
|
for idx, p in positions.items():
|
||||||
subevent_to_idx[p.subevent_id].append(idx)
|
subevent_to_idx[p.subevent_id].append(idx)
|
||||||
for v in subevent_to_idx.values():
|
for v in subevent_to_idx.values():
|
||||||
v.sort(key=lambda idx: positions[idx].line_price_gross)
|
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
||||||
|
|
||||||
# Build groups of exactly condition_min_count distinct subevents
|
# Build groups of exactly condition_min_count distinct subevents
|
||||||
@@ -458,7 +494,7 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
||||||
# and 2 from the end" scheme to optimize price distribution among groups
|
# and 2 from the end" scheme to optimize price distribution among groups
|
||||||
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
|
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
||||||
candidate = candidates[0]
|
candidate = candidates[0]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -594,10 +594,11 @@ class Item(LoggedModel):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
verbose_name=_("Only show after sellout of"),
|
verbose_name=_("Only show after sellout of"),
|
||||||
help_text=_("If you select a product here, this product will only be shown when that product is "
|
help_text=_("If you select a product here, this product will only be shown when that product is "
|
||||||
"sold out. If combined with the option to hide sold-out products, this allows you to "
|
"no longer available. This will happen either because the other product has sold out or because "
|
||||||
"swap out products for more expensive ones once the cheaper option is sold out. There might "
|
"the time is outside of the sales window for the other product. If combined with the option "
|
||||||
"be a short period in which both products are visible while all tickets of the referenced "
|
"to hide sold-out products, this allows you to swap out products for more expensive ones once "
|
||||||
"product are reserved, but not yet sold.")
|
"the cheaper option is sold out. There might be a short period in which both products are visible "
|
||||||
|
"while all tickets of the referenced product are reserved, but not yet sold.")
|
||||||
)
|
)
|
||||||
hidden_if_item_available_mode = models.CharField(
|
hidden_if_item_available_mode = models.CharField(
|
||||||
choices=UNAVAIL_MODES,
|
choices=UNAVAIL_MODES,
|
||||||
|
|||||||
@@ -141,8 +141,9 @@ class LogEntry(models.Model):
|
|||||||
|
|
||||||
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
||||||
if log_entry_type:
|
if log_entry_type:
|
||||||
|
sender = self.event if self.event else self.organizer
|
||||||
link_info = log_entry_type.get_object_link_info(self)
|
link_info = log_entry_type.get_object_link_info(self)
|
||||||
if is_app_active(self.event, meta['plugin']):
|
if is_app_active(sender, meta['plugin']):
|
||||||
return make_link(link_info, log_entry_type.object_link_wrapper)
|
return make_link(link_info, log_entry_type.object_link_wrapper)
|
||||||
else:
|
else:
|
||||||
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
|
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.mail import get_connection
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
|
|
||||||
|
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
|
||||||
|
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
|
||||||
|
cascade_objs = [
|
||||||
|
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
|
||||||
|
]
|
||||||
|
if cascade_objs:
|
||||||
|
models.CASCADE(collector, field, cascade_objs, using)
|
||||||
|
|
||||||
|
# In all other cases, set to NULL to keep the email on record
|
||||||
|
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMail(models.Model):
|
||||||
|
STATUS_QUEUED = "queued"
|
||||||
|
STATUS_WITHHELD = "withheld"
|
||||||
|
STATUS_INFLIGHT = "inflight"
|
||||||
|
STATUS_AWAITING_RETRY = "awaiting_retry"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
STATUS_SENT = "sent"
|
||||||
|
STATUS_BOUNCED = "bounced"
|
||||||
|
STATUS_ABORTED = "aborted"
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
(STATUS_QUEUED, _("queued")),
|
||||||
|
(STATUS_INFLIGHT, _("being sent")),
|
||||||
|
(STATUS_AWAITING_RETRY, _("awaiting retry")),
|
||||||
|
(STATUS_WITHHELD, _("withheld")), # for plugin use
|
||||||
|
(STATUS_FAILED, _("failed")),
|
||||||
|
(STATUS_ABORTED, _("aborted")),
|
||||||
|
(STATUS_SENT, _("sent")),
|
||||||
|
(STATUS_BOUNCED, _("bounced")), # for plugin use
|
||||||
|
)
|
||||||
|
STATUS_LIST_ABORTABLE = {
|
||||||
|
STATUS_QUEUED,
|
||||||
|
STATUS_WITHHELD,
|
||||||
|
STATUS_AWAITING_RETRY,
|
||||||
|
}
|
||||||
|
STATUS_LIST_RETRYABLE = {
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_WITHHELD,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
|
||||||
|
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
|
||||||
|
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
|
||||||
|
# their own Message-ID headers.
|
||||||
|
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# sent will be the time the email was sent or the email failed
|
||||||
|
sent = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
inflight_since = models.DateTimeField(null=True, blank=True)
|
||||||
|
retry_after = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
error = models.TextField(null=True, blank=True)
|
||||||
|
error_detail = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
# There is a conflict here between the different purposes of the model. As a system administrator,
|
||||||
|
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
|
||||||
|
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
|
||||||
|
# would be an "orphan" forever and there's no way to remove the personal information.
|
||||||
|
# We try to find a middle-ground with the following behaviour:
|
||||||
|
# - The email is always deleted if the entire organizer or user is deleted
|
||||||
|
# - The email is always deleted if it has not yet been sent
|
||||||
|
# - The email is kept in all other cases
|
||||||
|
# This is only an acceptable trade-off since emails are stored for a short period only, and because
|
||||||
|
# orders and customers are never deleted during normal operation. If we ever make this a long-term
|
||||||
|
# storage / email archive, we'd need to find another way to make sure personal information is removed
|
||||||
|
# if personal information of orders etc is removed.
|
||||||
|
organizer = models.ForeignKey(
|
||||||
|
'pretixbase.Organizer',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
'pretixbase.Event',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
order = models.ForeignKey(
|
||||||
|
'pretixbase.Order',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
orderposition = models.ForeignKey(
|
||||||
|
'pretixbase.OrderPosition',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
'pretixbase.Customer',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
'pretixbase.User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sensitive = models.BooleanField(default=False)
|
||||||
|
subject = models.TextField()
|
||||||
|
body_plain = models.TextField()
|
||||||
|
body_html = models.TextField(null=True)
|
||||||
|
sender = models.CharField(max_length=500)
|
||||||
|
headers = models.JSONField(default=dict)
|
||||||
|
to = models.JSONField(default=list)
|
||||||
|
cc = models.JSONField(default=list)
|
||||||
|
bcc = models.JSONField(default=list)
|
||||||
|
recipient_count = models.IntegerField()
|
||||||
|
|
||||||
|
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
|
||||||
|
# newer version of the files might be used. We accept that risk to save on storage and also because the new
|
||||||
|
# version might actually be more useful.
|
||||||
|
should_attach_invoices = models.ManyToManyField(
|
||||||
|
'pretixbase.Invoice',
|
||||||
|
related_name='outgoing_mails'
|
||||||
|
)
|
||||||
|
should_attach_tickets = models.BooleanField(default=False)
|
||||||
|
should_attach_ical = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
|
||||||
|
should_attach_cached_files = models.ManyToManyField(
|
||||||
|
'pretixbase.CachedFile',
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
)
|
||||||
|
|
||||||
|
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
|
||||||
|
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
|
||||||
|
should_attach_other_files = models.JSONField(default=list)
|
||||||
|
|
||||||
|
# [{name, type size}] of the attachments we actually setn
|
||||||
|
actual_attachments = models.JSONField(default=list)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('-created',)
|
||||||
|
|
||||||
|
def get_mail_backend(self):
|
||||||
|
if self.event:
|
||||||
|
return self.event.get_mail_backend()
|
||||||
|
elif self.organizer:
|
||||||
|
return self.organizer.get_mail_backend()
|
||||||
|
else:
|
||||||
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
|
def scope_manager(self):
|
||||||
|
if self.organizer:
|
||||||
|
return scope(organizer=self.organizer) # noqa
|
||||||
|
else:
|
||||||
|
return scopes_disabled() # noqa
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_failed(self):
|
||||||
|
return self.status in (
|
||||||
|
OutgoingMail.STATUS_FAILED,
|
||||||
|
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||||
|
OutgoingMail.STATUS_BOUNCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.orderposition_id and not self.order_id:
|
||||||
|
self.order = self.orderposition.order
|
||||||
|
if self.order_id and not self.event_id:
|
||||||
|
self.event = self.order.event
|
||||||
|
if self.event_id and not self.organizer_id:
|
||||||
|
self.organizer = self.event.organizer
|
||||||
|
if self.customer_id and not self.organizer_id:
|
||||||
|
self.organizer = self.customer.organizer
|
||||||
|
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def log_parameters(self):
|
||||||
|
if self.order:
|
||||||
|
error_log_action_type = 'pretix.event.order.email.error'
|
||||||
|
log_target = self.order
|
||||||
|
elif self.customer:
|
||||||
|
error_log_action_type = 'pretix.customer.email.error'
|
||||||
|
log_target = self.customer
|
||||||
|
elif self.user:
|
||||||
|
error_log_action_type = 'pretix.user.email.error'
|
||||||
|
log_target = self.user
|
||||||
|
else:
|
||||||
|
error_log_action_type = 'pretix.email.error'
|
||||||
|
log_target = None
|
||||||
|
return log_target, error_log_action_type
|
||||||
@@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now
|
|||||||
|
|
||||||
from ...helpers import OF_SELF
|
from ...helpers import OF_SELF
|
||||||
from ...helpers.countries import CachedCountries, FastCountryField
|
from ...helpers.countries import CachedCountries, FastCountryField
|
||||||
from ...helpers.format import format_map
|
from ...helpers.format import FormattedString, format_map
|
||||||
from ...helpers.names import build_name
|
from ...helpers.names import build_name
|
||||||
from ...testutils.middleware import debugflags_var
|
from ...testutils.middleware import debugflags_var
|
||||||
from ._transactions import (
|
from ._transactions import (
|
||||||
@@ -1167,9 +1167,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
only be attached for this position and child positions, the link will only point to the
|
only be attached for this position and child positions, the link will only point to the
|
||||||
position and the attendee email will be used if available.
|
position and the attendee email will be used if available.
|
||||||
"""
|
"""
|
||||||
from pretix.base.services.mail import (
|
from pretix.base.services.mail import mail, render_mail
|
||||||
SendMailException, mail, render_mail,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.email and not (position and position.attendee_email):
|
if not self.email and not (position and position.attendee_email):
|
||||||
return
|
return
|
||||||
@@ -1179,35 +1177,32 @@ class Order(LockModel, LoggedModel):
|
|||||||
if position and position.attendee_email:
|
if position and position.attendee_email:
|
||||||
recipient = position.attendee_email
|
recipient = position.attendee_email
|
||||||
|
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
if not isinstance(subject, FormattedString):
|
||||||
subject = format_map(subject, context)
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
invoices=invoices, attach_tickets=attach_tickets,
|
invoices=invoices, attach_tickets=attach_tickets,
|
||||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||||
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
self.log_action(
|
||||||
raise
|
log_entry_type,
|
||||||
else:
|
user=user,
|
||||||
self.log_action(
|
auth=auth,
|
||||||
log_entry_type,
|
data={
|
||||||
user=user,
|
'subject': subject,
|
||||||
auth=auth,
|
'message': email_content,
|
||||||
data={
|
'position': position.positionid if position else None,
|
||||||
'subject': subject,
|
'recipient': recipient,
|
||||||
'message': email_content,
|
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||||
'position': position.positionid if position else None,
|
'attach_tickets': attach_tickets,
|
||||||
'recipient': recipient,
|
'attach_ical': attach_ical,
|
||||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
'attach_other_files': attach_other_files,
|
||||||
'attach_tickets': attach_tickets,
|
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
'attach_ical': attach_ical,
|
}
|
||||||
'attach_other_files': attach_other_files,
|
)
|
||||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
@@ -1675,7 +1670,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -2024,40 +2019,30 @@ class OrderPayment(models.Model):
|
|||||||
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
|
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
|
||||||
|
|
||||||
def _send_paid_mail_attendee(self, position, user):
|
def _send_paid_mail_attendee(self, position, user):
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||||
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
|
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||||
try:
|
position.send_mail(
|
||||||
position.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[],
|
||||||
invoices=[],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
attach_ical=self.order.event.settings.mail_attach_ical
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order paid email could not be sent')
|
|
||||||
|
|
||||||
def _send_paid_mail(self, invoice, user, mail_text):
|
def _send_paid_mail(self, invoice, user, mail_text):
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid
|
email_template = self.order.event.settings.mail_text_order_paid
|
||||||
email_subject = self.order.event.settings.mail_subject_order_paid
|
email_subject = self.order.event.settings.mail_subject_order_paid
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
||||||
try:
|
self.order.send_mail(
|
||||||
self.order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[invoice] if invoice else [],
|
||||||
invoices=[invoice] if invoice else [],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
attach_ical=self.order.event.settings.mail_attach_ical
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order paid email could not be sent')
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def refunded_amount(self):
|
def refunded_amount(self):
|
||||||
@@ -2915,45 +2900,40 @@ class OrderPosition(AbstractPosition):
|
|||||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||||
:param attach_ical: Attach relevant ICS files
|
:param attach_ical: Attach relevant ICS files
|
||||||
"""
|
"""
|
||||||
from pretix.base.services.mail import (
|
from pretix.base.services.mail import mail, render_mail
|
||||||
SendMailException, mail, render_mail,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.attendee_email:
|
if not self.attendee_email:
|
||||||
return
|
return
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
recipient = self.attendee_email
|
recipient = self.attendee_email
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
if not isinstance(subject, FormattedString):
|
||||||
subject = format_map(subject, context)
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||||
position=self,
|
position=self,
|
||||||
invoices=invoices,
|
invoices=invoices,
|
||||||
attach_tickets=attach_tickets,
|
attach_tickets=attach_tickets,
|
||||||
attach_ical=attach_ical,
|
attach_ical=attach_ical,
|
||||||
attach_other_files=attach_other_files,
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
self.order.log_action(
|
||||||
raise
|
log_entry_type,
|
||||||
else:
|
user=user,
|
||||||
self.order.log_action(
|
auth=auth,
|
||||||
log_entry_type,
|
data={
|
||||||
user=user,
|
'subject': subject,
|
||||||
auth=auth,
|
'message': email_content,
|
||||||
data={
|
'recipient': recipient,
|
||||||
'subject': subject,
|
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||||
'message': email_content,
|
'attach_tickets': attach_tickets,
|
||||||
'recipient': recipient,
|
'attach_ical': attach_ical,
|
||||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
'attach_other_files': attach_other_files,
|
||||||
'attach_tickets': attach_tickets,
|
'attach_cached_files': [],
|
||||||
'attach_ical': attach_ical,
|
}
|
||||||
'attach_other_files': attach_other_files,
|
)
|
||||||
'attach_cached_files': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
|
|
||||||
@@ -3480,7 +3460,7 @@ class InvoiceAddress(models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3529,18 +3509,10 @@ class InvoiceAddress(models.Model):
|
|||||||
def describe_transmission(self):
|
def describe_transmission(self):
|
||||||
from pretix.base.invoicing.transmission import transmission_types
|
from pretix.base.invoicing.transmission import transmission_types
|
||||||
data = []
|
data = []
|
||||||
|
|
||||||
t, __ = transmission_types.get(identifier=self.transmission_type)
|
t, __ = transmission_types.get(identifier=self.transmission_type)
|
||||||
data.append((_("Transmission type"), t.public_name))
|
data.append((_("Transmission type"), t.public_name))
|
||||||
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
|
if self.transmission_info:
|
||||||
for k, f in t.invoice_address_form_fields.items():
|
data += t.describe_info(self.transmission_info, self.country, self.is_business)
|
||||||
v = form_data.get(k)
|
|
||||||
if v is True:
|
|
||||||
v = _("Yes")
|
|
||||||
elif v is False:
|
|
||||||
v = _("No")
|
|
||||||
if v:
|
|
||||||
data.append((f.label, v))
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ from phonenumber_field.modelfields import PhoneNumberField
|
|||||||
from pretix.base.email import get_email_context
|
from pretix.base.email import get_email_context
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import User, Voucher
|
from pretix.base.models import User, Voucher
|
||||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
from pretix.base.services.mail import mail, render_mail
|
||||||
|
from pretix.helpers import OF_SELF
|
||||||
|
|
||||||
from ...helpers.format import format_map
|
from ...helpers.format import format_map
|
||||||
from ...helpers.names import build_name
|
from ...helpers.names import build_name
|
||||||
@@ -158,6 +159,7 @@ class WaitingListEntry(LoggedModel):
|
|||||||
if availability[1] is None or availability[1] < 1:
|
if availability[1] is None or availability[1] < 1:
|
||||||
raise WaitingListException(_('This product is currently not available.'))
|
raise WaitingListException(_('This product is currently not available.'))
|
||||||
|
|
||||||
|
event = self.event
|
||||||
ev = self.subevent or self.event
|
ev = self.subevent or self.event
|
||||||
if ev.seat_category_mappings.filter(product=self.item).exists():
|
if ev.seat_category_mappings.filter(product=self.item).exists():
|
||||||
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
||||||
@@ -185,44 +187,49 @@ class WaitingListEntry(LoggedModel):
|
|||||||
if not free_seats:
|
if not free_seats:
|
||||||
raise WaitingListException(_('No seat with this product is currently available.'))
|
raise WaitingListException(_('No seat with this product is currently available.'))
|
||||||
|
|
||||||
if self.voucher:
|
|
||||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
|
||||||
if '@' not in self.email:
|
if '@' not in self.email:
|
||||||
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
|
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
e = self.email
|
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
|
||||||
if self.name:
|
locked_wle.event = event
|
||||||
e += ' / ' + self.name
|
if locked_wle.voucher:
|
||||||
|
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||||
|
e = locked_wle.email
|
||||||
|
if locked_wle.name:
|
||||||
|
e += ' / ' + locked_wle.name
|
||||||
v = Voucher.objects.create(
|
v = Voucher.objects.create(
|
||||||
event=self.event,
|
event=locked_wle.event,
|
||||||
max_usages=1,
|
max_usages=1,
|
||||||
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
|
valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours),
|
||||||
item=self.item,
|
item=locked_wle.item,
|
||||||
variation=self.variation,
|
variation=locked_wle.variation,
|
||||||
tag='waiting-list',
|
tag='waiting-list',
|
||||||
comment=_('Automatically created from waiting list entry for {email}').format(
|
comment=_('Automatically created from waiting list entry for {email}').format(
|
||||||
email=e
|
email=e
|
||||||
),
|
),
|
||||||
block_quota=True,
|
block_quota=True,
|
||||||
subevent=self.subevent,
|
subevent=locked_wle.subevent,
|
||||||
)
|
)
|
||||||
v.log_action('pretix.voucher.added', {
|
v.log_action('pretix.voucher.added', {
|
||||||
'item': self.item.pk,
|
'item': locked_wle.item.pk,
|
||||||
'variation': self.variation.pk if self.variation else None,
|
'variation': locked_wle.variation.pk if locked_wle.variation else None,
|
||||||
'tag': 'waiting-list',
|
'tag': 'waiting-list',
|
||||||
'block_quota': True,
|
'block_quota': True,
|
||||||
'valid_until': v.valid_until.isoformat(),
|
'valid_until': v.valid_until.isoformat(),
|
||||||
'max_usages': 1,
|
'max_usages': 1,
|
||||||
'subevent': self.subevent.pk if self.subevent else None,
|
'subevent': locked_wle.subevent.pk if locked_wle.subevent else None,
|
||||||
'source': 'waitinglist',
|
'source': 'waitinglist',
|
||||||
}, user=user, auth=auth)
|
}, user=user, auth=auth)
|
||||||
v.log_action('pretix.voucher.added.waitinglist', {
|
v.log_action('pretix.voucher.added.waitinglist', {
|
||||||
'email': self.email,
|
'email': locked_wle.email,
|
||||||
'waitinglistentry': self.pk,
|
'waitinglistentry': locked_wle.pk,
|
||||||
}, user=user, auth=auth)
|
}, user=user, auth=auth)
|
||||||
self.voucher = v
|
locked_wle.voucher = v
|
||||||
self.save()
|
locked_wle.save()
|
||||||
|
|
||||||
|
self.refresh_from_db()
|
||||||
|
self.event = event
|
||||||
|
|
||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
self.send_mail(
|
self.send_mail(
|
||||||
@@ -265,34 +272,30 @@ class WaitingListEntry(LoggedModel):
|
|||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
recipient = self.email
|
recipient = self.email
|
||||||
|
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
subject = format_map(subject, context)
|
||||||
subject = format_map(subject, context)
|
mail(
|
||||||
mail(
|
recipient, subject, template, context,
|
||||||
recipient, subject, template, context,
|
self.event,
|
||||||
self.event,
|
self.locale,
|
||||||
self.locale,
|
headers=headers,
|
||||||
headers=headers,
|
sender=sender,
|
||||||
sender=sender,
|
auto_email=auto_email,
|
||||||
auto_email=auto_email,
|
attach_other_files=attach_other_files,
|
||||||
attach_other_files=attach_other_files,
|
attach_cached_files=attach_cached_files,
|
||||||
attach_cached_files=attach_cached_files,
|
)
|
||||||
)
|
self.log_action(
|
||||||
except SendMailException:
|
log_entry_type,
|
||||||
raise
|
user=user,
|
||||||
else:
|
auth=auth,
|
||||||
self.log_action(
|
data={
|
||||||
log_entry_type,
|
'subject': subject,
|
||||||
user=user,
|
'message': email_content,
|
||||||
auth=auth,
|
'recipient': recipient,
|
||||||
data={
|
'attach_other_files': attach_other_files,
|
||||||
'subject': subject,
|
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
'message': email_content,
|
}
|
||||||
'recipient': recipient,
|
)
|
||||||
'attach_other_files': attach_other_files,
|
|
||||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_itemvar(event, item, variation):
|
def clean_itemvar(event, item, variation):
|
||||||
|
|||||||
@@ -1231,8 +1231,8 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
||||||
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order):
|
def order_change_allowed(self, order: Order, request=None):
|
||||||
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order, request)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_name(self):
|
def public_name(self):
|
||||||
@@ -1646,6 +1646,13 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
'transaction_id': trans.pk,
|
'transaction_id': trans.pk,
|
||||||
}
|
}
|
||||||
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
|
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.payment',
|
||||||
|
data={
|
||||||
|
'value': trans.value,
|
||||||
|
'acceptor_id': self.event.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
payment.fail(info={'error': str(e)})
|
payment.fail(info={'error': str(e)})
|
||||||
raise e
|
raise e
|
||||||
@@ -1670,6 +1677,14 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
'transaction_id': trans.pk,
|
'transaction_id': trans.pk,
|
||||||
}
|
}
|
||||||
refund.done()
|
refund.done()
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.refund',
|
||||||
|
data={
|
||||||
|
'value': refund.amount,
|
||||||
|
'acceptor_id': self.event.organizer.id,
|
||||||
|
'text': refund.comment,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
|
|||||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
|
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
|
||||||
if level == PLUGIN_LEVEL_EVENT:
|
if level == PLUGIN_LEVEL_EVENT:
|
||||||
if event and hasattr(app, 'is_available'):
|
if event and hasattr(app, 'is_available'):
|
||||||
if not app.is_available(event):
|
if not app.is_available(event):
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from pretix.base.models import (
|
|||||||
SubEvent, TaxRule, User, WaitingListEntry,
|
SubEvent, TaxRule, User, WaitingListEntry,
|
||||||
)
|
)
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||||
)
|
)
|
||||||
@@ -53,17 +53,14 @@ logger = logging.getLogger(__name__)
|
|||||||
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
||||||
with language(wle.locale, wle.event.settings.region):
|
with language(wle.locale, wle.event.settings.region):
|
||||||
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
||||||
try:
|
mail(
|
||||||
mail(
|
wle.email,
|
||||||
wle.email,
|
format_map(subject, email_context),
|
||||||
format_map(subject, email_context),
|
message,
|
||||||
message,
|
email_context,
|
||||||
email_context,
|
wle.event,
|
||||||
wle.event,
|
locale=wle.locale
|
||||||
locale=wle.locale
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Waiting list canceled email could not be sent')
|
|
||||||
|
|
||||||
|
|
||||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||||
@@ -77,14 +74,11 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
|||||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||||
order=order, position_or_address=ia, event=order.event)
|
order=order, position_or_address=ia, event=order.event)
|
||||||
real_subject = format_map(subject, email_context)
|
real_subject = format_map(subject, email_context)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
real_subject, message, email_context,
|
||||||
real_subject, message, email_context,
|
'pretix.event.order.email.event_canceled',
|
||||||
'pretix.event.order.email.event_canceled',
|
user,
|
||||||
user,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order canceled email could not be sent')
|
|
||||||
|
|
||||||
for p in positions:
|
for p in positions:
|
||||||
if subevent and p.subevent_id != subevent.id:
|
if subevent and p.subevent_id != subevent.id:
|
||||||
@@ -97,15 +91,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
|||||||
refund_amount=refund_amount,
|
refund_amount=refund_amount,
|
||||||
position_or_address=p,
|
position_or_address=p,
|
||||||
order=order, position=p)
|
order=order, position=p)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
real_subject, message, email_context,
|
||||||
real_subject, message, email_context,
|
'pretix.event.order.email.event_canceled',
|
||||||
'pretix.event.order.email.event_canceled',
|
position=p,
|
||||||
position=p,
|
user=user
|
||||||
user=user
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order canceled email could not be sent to attendee')
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||||
|
|||||||
@@ -1528,7 +1528,7 @@ class CartManager:
|
|||||||
self._sales_channel.identifier,
|
self._sales_channel.identifier,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||||
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||||
for cp in positions
|
for cp in positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1639,7 +1639,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
|
|||||||
if fee.tax_rule and not fee.tax_rule.pk:
|
if fee.tax_rule and not fee.tax_rule.pk:
|
||||||
fee.tax_rule = None # TODO: deprecate
|
fee.tax_rule = None # TODO: deprecate
|
||||||
|
|
||||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
|
||||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
if total != 0 and payments:
|
if total != 0 and payments:
|
||||||
@@ -1679,7 +1679,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
|
|||||||
fees.append(pf)
|
fees.append(pf)
|
||||||
|
|
||||||
# Re-apply rounding as grand total has changed
|
# Re-apply rounding as grand total has changed
|
||||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
|
||||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
# Re-calculate to_pay as grand total has changed
|
# Re-calculate to_pay as grand total has changed
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import CachedCombinedTicket, CachedTicket
|
from pretix.base.models import CachedCombinedTicket, CachedTicket, OutgoingMail
|
||||||
from pretix.base.models.customers import CustomerSSOGrant
|
from pretix.base.models.customers import CustomerSSOGrant
|
||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
@@ -49,7 +50,18 @@ def clean_cart_positions(sender, **kwargs):
|
|||||||
@receiver(signal=periodic_task)
|
@receiver(signal=periodic_task)
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def clean_cached_files(sender, **kwargs):
|
def clean_cached_files(sender, **kwargs):
|
||||||
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
|
has_queued_email = Exists(
|
||||||
|
OutgoingMail.objects.filter(
|
||||||
|
should_attach_cached_files__pk=OuterRef("pk"),
|
||||||
|
status__in=(
|
||||||
|
OutgoingMail.STATUS_QUEUED,
|
||||||
|
OutgoingMail.STATUS_INFLIGHT,
|
||||||
|
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||||
|
OutgoingMail.STATUS_FAILED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()).exclude(has_queued_email):
|
||||||
cf.delete()
|
cf.delete()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class CrossSellingService:
|
|||||||
self.sales_channel,
|
self.sales_channel,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||||
bool(cp.addon_to), cp.is_bundled,
|
cp.addon_to, cp.is_bundled,
|
||||||
cp.listed_price - cp.price_after_voucher)
|
cp.listed_price - cp.price_after_voucher)
|
||||||
for cp in self.cartpositions
|
for cp in self.cartpositions
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -521,9 +521,20 @@ def invoice_pdf_task(invoice: int):
|
|||||||
|
|
||||||
|
|
||||||
def invoice_qualified(order: Order):
|
def invoice_qualified(order: Order):
|
||||||
if order.total == Decimal('0.00') or order.require_approval or \
|
if order.total == Decimal('0.00'):
|
||||||
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
|
|
||||||
return False
|
return False
|
||||||
|
if order.require_approval:
|
||||||
|
return False
|
||||||
|
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
|
||||||
|
return False
|
||||||
|
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
|
||||||
|
return False
|
||||||
|
if order.event.settings.invoice_generate_only_business:
|
||||||
|
try:
|
||||||
|
ia = order.invoice_address
|
||||||
|
return ia.is_business
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+653
-408
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import uuid
|
||||||
|
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
@@ -26,7 +28,9 @@ from django.utils.timezone import override
|
|||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import LogEntry, NotificationSetting, User
|
from pretix.base.models import (
|
||||||
|
LogEntry, NotificationSetting, OutgoingMail, User,
|
||||||
|
)
|
||||||
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
|
||||||
@@ -153,16 +157,26 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||||
body_plain = tpl_plain.render(ctx)
|
body_plain = tpl_plain.render(ctx)
|
||||||
|
|
||||||
mail_send_task.apply_async(kwargs={
|
guid = uuid.uuid4()
|
||||||
'to': [user.email],
|
m = OutgoingMail.objects.create(
|
||||||
'subject': '[{}] {}: {}'.format(
|
guid=guid,
|
||||||
|
user=user,
|
||||||
|
to=[user.email],
|
||||||
|
subject='[{}] {}: {}'.format(
|
||||||
settings.PRETIX_INSTANCE_NAME,
|
settings.PRETIX_INSTANCE_NAME,
|
||||||
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
||||||
notification.title
|
notification.title
|
||||||
),
|
),
|
||||||
'body': body_plain,
|
body_plain=body_plain,
|
||||||
'html': body_html,
|
body_html=body_html,
|
||||||
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
sender=settings.MAIL_FROM_NOTIFICATIONS,
|
||||||
'headers': {},
|
headers={
|
||||||
'user': user.pk
|
'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN',
|
||||||
|
'Auto-Submitted': 'auto-generated',
|
||||||
|
'X-Mailer': 'pretix',
|
||||||
|
'X-PX-Correlation': str(guid),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mail_send_task.apply_async(kwargs={
|
||||||
|
'outgoing_mail': m.pk,
|
||||||
})
|
})
|
||||||
|
|||||||
+187
-147
@@ -90,7 +90,6 @@ from pretix.base.services.invoices import (
|
|||||||
from pretix.base.services.locking import (
|
from pretix.base.services.locking import (
|
||||||
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
|
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
from pretix.base.services.memberships import (
|
from pretix.base.services.memberships import (
|
||||||
create_membership, validate_memberships_in_order,
|
create_membership, validate_memberships_in_order,
|
||||||
)
|
)
|
||||||
@@ -248,6 +247,15 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
|||||||
for gc in position.issued_gift_cards.all():
|
for gc in position.issued_gift_cards.all():
|
||||||
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
|
||||||
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
|
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=user,
|
||||||
|
auth=auth,
|
||||||
|
data={
|
||||||
|
'value': position.price,
|
||||||
|
'acceptor_id': order.event.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
for m in position.granted_memberships.all():
|
for m in position.granted_memberships.all():
|
||||||
@@ -438,33 +446,27 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
|||||||
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
|
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
|
||||||
|
|
||||||
email_context = get_email_context(event=order.event, order=order)
|
email_context = get_email_context(event=order.event, order=order)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_approved', user,
|
||||||
'pretix.event.order.email.order_approved', user,
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=order.event.settings.mail_attach_ical and (
|
||||||
attach_ical=order.event.settings.mail_attach_ical and (
|
not order.event.settings.mail_attach_ical_paid_only or
|
||||||
not order.event.settings.mail_attach_ical_paid_only or
|
order.total == Decimal('0.00') or
|
||||||
order.total == Decimal('0.00') or
|
order.valid_if_pending
|
||||||
order.valid_if_pending
|
),
|
||||||
),
|
invoices=[invoice] if invoice and transmit_invoice_mail else []
|
||||||
invoices=[invoice] if invoice and transmit_invoice_mail else []
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order approved email could not be sent')
|
|
||||||
|
|
||||||
if email_attendees:
|
if email_attendees:
|
||||||
for p in order.positions.all():
|
for p in order.positions.all():
|
||||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||||
email_attendee_context = get_email_context(event=order.event, order=order, position=p)
|
email_attendee_context = get_email_context(event=order.event, order=order, position=p)
|
||||||
try:
|
p.send_mail(
|
||||||
p.send_mail(
|
email_attendee_subject, email_attendee_template, email_attendee_context,
|
||||||
email_attendee_subject, email_attendee_template, email_attendee_context,
|
'pretix.event.order.email.order_approved', user,
|
||||||
'pretix.event.order.email.order_approved', user,
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order approved email could not be sent to attendee')
|
|
||||||
|
|
||||||
return order.pk
|
return order.pk
|
||||||
|
|
||||||
@@ -501,13 +503,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
|||||||
email_template = order.event.settings.mail_text_order_denied
|
email_template = order.event.settings.mail_text_order_denied
|
||||||
email_subject = order.event.settings.mail_subject_order_denied
|
email_subject = order.event.settings.mail_subject_order_denied
|
||||||
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
email_context = get_email_context(event=order.event, order=order, comment=comment)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_denied', user
|
||||||
'pretix.event.order.email.order_denied', user
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order denied email could not be sent')
|
|
||||||
|
|
||||||
return order.pk
|
return order.pk
|
||||||
|
|
||||||
@@ -558,6 +557,14 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
|
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=user,
|
||||||
|
data={
|
||||||
|
'value': -position.price,
|
||||||
|
'acceptor_id': order.event.organizer.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for m in position.granted_memberships.all():
|
for m in position.granted_memberships.all():
|
||||||
m.canceled = True
|
m.canceled = True
|
||||||
@@ -660,14 +667,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
|||||||
email_template = order.event.settings.mail_text_order_canceled
|
email_template = order.event.settings.mail_text_order_canceled
|
||||||
email_subject = order.event.settings.mail_subject_order_canceled
|
email_subject = order.event.settings.mail_subject_order_canceled
|
||||||
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
|
email_context = get_email_context(event=order.event, order=order, comment=comment or "")
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_canceled', user,
|
||||||
'pretix.event.order.email.order_canceled', user,
|
invoices=transmit_invoices_mail,
|
||||||
invoices=transmit_invoices_mail,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order canceled email could not be sent')
|
|
||||||
|
|
||||||
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
|
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
|
||||||
try:
|
try:
|
||||||
@@ -914,7 +918,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
sales_channel.identifier,
|
sales_channel.identifier,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||||
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||||
for cp in sorted_positions
|
for cp in sorted_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -968,7 +972,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
|
|||||||
fee.tax_rule = None # TODO: deprecate
|
fee.tax_rule = None # TODO: deprecate
|
||||||
|
|
||||||
# Apply rounding to get final total in case no payment fees will be added
|
# Apply rounding to get final total in case no payment fees will be added
|
||||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
|
||||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
payments_assigned = Decimal("0.00")
|
payments_assigned = Decimal("0.00")
|
||||||
@@ -995,7 +999,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
|
|||||||
p['fee'] = pf
|
p['fee'] = pf
|
||||||
|
|
||||||
# Re-apply rounding as grand total has changed
|
# Re-apply rounding as grand total has changed
|
||||||
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
|
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
|
||||||
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
# Re-calculate to_pay as grand total has changed
|
# Re-calculate to_pay as grand total has changed
|
||||||
@@ -1108,46 +1112,40 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
|
|||||||
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
|
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
|
||||||
email_context = get_email_context(event=event, order=order, payments=payments)
|
email_context = get_email_context(event=event, order=order, payments=payments)
|
||||||
|
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
subject_template, email_template, email_context,
|
||||||
subject_template, email_template, email_context,
|
log_entry,
|
||||||
log_entry,
|
invoices=[invoice] if invoice else [],
|
||||||
invoices=[invoice] if invoice else [],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=event.settings.mail_attach_ical and (
|
||||||
attach_ical=event.settings.mail_attach_ical and (
|
not event.settings.mail_attach_ical_paid_only or
|
||||||
not event.settings.mail_attach_ical_paid_only or
|
is_free or
|
||||||
is_free or
|
order.valid_if_pending
|
||||||
order.valid_if_pending
|
),
|
||||||
),
|
attach_other_files=[a for a in [
|
||||||
attach_other_files=[a for a in [
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
] if a],
|
||||||
] if a],
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order received email could not be sent')
|
|
||||||
|
|
||||||
|
|
||||||
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
|
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
|
||||||
log_entry: str, is_free=False):
|
log_entry: str, is_free=False):
|
||||||
email_context = get_email_context(event=event, order=order, position=position)
|
email_context = get_email_context(event=event, order=order, position=position)
|
||||||
|
|
||||||
try:
|
position.send_mail(
|
||||||
position.send_mail(
|
subject_template, email_template, email_context,
|
||||||
subject_template, email_template, email_context,
|
log_entry,
|
||||||
log_entry,
|
invoices=[],
|
||||||
invoices=[],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=event.settings.mail_attach_ical and (
|
||||||
attach_ical=event.settings.mail_attach_ical and (
|
not event.settings.mail_attach_ical_paid_only or
|
||||||
not event.settings.mail_attach_ical_paid_only or
|
is_free or
|
||||||
is_free or
|
order.valid_if_pending
|
||||||
order.valid_if_pending
|
),
|
||||||
),
|
attach_other_files=[a for a in [
|
||||||
attach_other_files=[a for a in [
|
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
] if a],
|
||||||
] if a],
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order received email could not be sent to attendee')
|
|
||||||
|
|
||||||
|
|
||||||
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
|
||||||
@@ -1476,13 +1474,10 @@ def send_expiry_warnings(sender, **kwargs):
|
|||||||
email_template = settings.mail_text_order_pending_warning
|
email_template = settings.mail_text_order_pending_warning
|
||||||
email_subject = settings.mail_subject_order_pending_warning
|
email_subject = settings.mail_subject_order_pending_warning
|
||||||
|
|
||||||
try:
|
o.send_mail(
|
||||||
o.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.expire_warning_sent'
|
||||||
'pretix.event.order.email.expire_warning_sent'
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Reminder email could not be sent')
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(signal=periodic_task)
|
@receiver(signal=periodic_task)
|
||||||
@@ -1543,14 +1538,11 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
email_template = event.settings.mail_text_download_reminder
|
email_template = event.settings.mail_text_download_reminder
|
||||||
email_subject = event.settings.mail_subject_download_reminder
|
email_subject = event.settings.mail_subject_download_reminder
|
||||||
email_context = get_email_context(event=event, order=o)
|
email_context = get_email_context(event=event, order=o)
|
||||||
try:
|
o.send_mail(
|
||||||
o.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.download_reminder_sent',
|
||||||
'pretix.event.order.email.download_reminder_sent',
|
attach_tickets=True
|
||||||
attach_tickets=True
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Reminder email could not be sent')
|
|
||||||
|
|
||||||
if event.settings.mail_send_download_reminder_attendee:
|
if event.settings.mail_send_download_reminder_attendee:
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -1564,14 +1556,11 @@ def send_download_reminders(sender, **kwargs):
|
|||||||
email_template = event.settings.mail_text_download_reminder_attendee
|
email_template = event.settings.mail_text_download_reminder_attendee
|
||||||
email_subject = event.settings.mail_subject_download_reminder_attendee
|
email_subject = event.settings.mail_subject_download_reminder_attendee
|
||||||
email_context = get_email_context(event=event, order=o, position=p)
|
email_context = get_email_context(event=event, order=o, position=p)
|
||||||
try:
|
o.send_mail(
|
||||||
o.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.download_reminder_sent',
|
||||||
'pretix.event.order.email.download_reminder_sent',
|
attach_tickets=True, position=p
|
||||||
attach_tickets=True, position=p
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Reminder email could not be sent to attendee')
|
|
||||||
|
|
||||||
|
|
||||||
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
||||||
@@ -1579,13 +1568,10 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
|
|||||||
email_template = order.event.settings.mail_text_order_changed
|
email_template = order.event.settings.mail_text_order_changed
|
||||||
email_context = get_email_context(event=order.event, order=order)
|
email_context = get_email_context(event=order.event, order=order)
|
||||||
email_subject = order.event.settings.mail_subject_order_changed
|
email_subject = order.event.settings.mail_subject_order_changed
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
|
||||||
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order changed email could not be sent')
|
|
||||||
|
|
||||||
|
|
||||||
class OrderChangeManager:
|
class OrderChangeManager:
|
||||||
@@ -1641,6 +1627,7 @@ class OrderChangeManager:
|
|||||||
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
|
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
|
||||||
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
||||||
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
|
||||||
|
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
|
||||||
|
|
||||||
class AddPositionResult:
|
class AddPositionResult:
|
||||||
_position: Optional[OrderPosition]
|
_position: Optional[OrderPosition]
|
||||||
@@ -1804,6 +1791,7 @@ class OrderChangeManager:
|
|||||||
positions = self.order.positions.select_related('item', 'item__tax_rule')
|
positions = self.order.positions.select_related('item', 'item__tax_rule')
|
||||||
ia = self._invoice_address
|
ia = self._invoice_address
|
||||||
tax_rules = self._current_tax_rules()
|
tax_rules = self._current_tax_rules()
|
||||||
|
self._operations.append(self.ForceRecomputeOperation())
|
||||||
|
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
|
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
|
||||||
@@ -2094,6 +2082,43 @@ class OrderChangeManager:
|
|||||||
)
|
)
|
||||||
item_counts[item] += 1
|
item_counts[item] += 1
|
||||||
|
|
||||||
|
# Detect removed add-ons and create RemoveOperations
|
||||||
|
for cp, al in list(current_addons.items()):
|
||||||
|
for k, v in al.items():
|
||||||
|
input_num = input_addons[cp.id].get(k, 0)
|
||||||
|
current_num = len(current_addons[cp].get(k, []))
|
||||||
|
if input_num < current_num:
|
||||||
|
for a in current_addons[cp][k][:current_num - input_num]:
|
||||||
|
if a.canceled:
|
||||||
|
continue
|
||||||
|
is_unavailable = (
|
||||||
|
# If an item is no longer available due to time, it should usually also be no longer
|
||||||
|
# user-removable, because e.g. the stock has already been ordered.
|
||||||
|
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
||||||
|
# not mean it should be unremovable for others.
|
||||||
|
# This also prevents accidental removal through the UI because a hidden product will no longer
|
||||||
|
# be part of the input.
|
||||||
|
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
||||||
|
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
|
||||||
|
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
||||||
|
or (
|
||||||
|
not a.item.all_sales_channels and
|
||||||
|
not a.item.limit_sales_channels.contains(self.order.sales_channel)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if is_unavailable:
|
||||||
|
# "Re-select" add-on
|
||||||
|
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
|
||||||
|
continue
|
||||||
|
if a.checkins.filter(list__consider_tickets_used=True).exists():
|
||||||
|
raise OrderError(
|
||||||
|
error_messages['addon_already_checked_in'] % {
|
||||||
|
'addon': str(a.item.name),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.cancel(a)
|
||||||
|
item_counts[a.item] -= 1
|
||||||
|
|
||||||
# Check constraints on the add-on combinations
|
# Check constraints on the add-on combinations
|
||||||
for op in toplevel_op:
|
for op in toplevel_op:
|
||||||
item = op.item
|
item = op.item
|
||||||
@@ -2126,41 +2151,6 @@ class OrderChangeManager:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Detect removed add-ons and create RemoveOperations
|
|
||||||
for cp, al in list(current_addons.items()):
|
|
||||||
for k, v in al.items():
|
|
||||||
input_num = input_addons[cp.id].get(k, 0)
|
|
||||||
current_num = len(current_addons[cp].get(k, []))
|
|
||||||
if input_num < current_num:
|
|
||||||
for a in current_addons[cp][k][:current_num - input_num]:
|
|
||||||
if a.canceled:
|
|
||||||
continue
|
|
||||||
is_unavailable = (
|
|
||||||
# If an item is no longer available due to time, it should usually also be no longer
|
|
||||||
# user-removable, because e.g. the stock has already been ordered.
|
|
||||||
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
|
||||||
# not mean it should be unremovable for others.
|
|
||||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
|
||||||
# be part of the input.
|
|
||||||
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
|
||||||
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
|
|
||||||
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
|
||||||
or (
|
|
||||||
not item.all_sales_channels and
|
|
||||||
not item.limit_sales_channels.contains(self.order.sales_channel)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if is_unavailable:
|
|
||||||
continue
|
|
||||||
if a.checkins.filter(list__consider_tickets_used=True).exists():
|
|
||||||
raise OrderError(
|
|
||||||
error_messages['addon_already_checked_in'] % {
|
|
||||||
'addon': str(a.item.name),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.cancel(a)
|
|
||||||
item_counts[a.item] -= 1
|
|
||||||
|
|
||||||
for item, count in item_counts.items():
|
for item, count in item_counts.items():
|
||||||
if count == 0:
|
if count == 0:
|
||||||
continue
|
continue
|
||||||
@@ -2461,6 +2451,15 @@ class OrderChangeManager:
|
|||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer)
|
gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.user,
|
||||||
|
auth=self.auth,
|
||||||
|
data={
|
||||||
|
'value': -position.price,
|
||||||
|
'acceptor_id': self.order.event.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for m in position.granted_memberships.with_usages().all():
|
for m in position.granted_memberships.with_usages().all():
|
||||||
m.canceled = True
|
m.canceled = True
|
||||||
@@ -2478,6 +2477,15 @@ class OrderChangeManager:
|
|||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
|
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.user,
|
||||||
|
auth=self.auth,
|
||||||
|
data={
|
||||||
|
'value': -opa.position.price,
|
||||||
|
'acceptor_id': self.order.event.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for m in opa.granted_memberships.with_usages().all():
|
for m in opa.granted_memberships.with_usages().all():
|
||||||
m.canceled = True
|
m.canceled = True
|
||||||
@@ -2640,6 +2648,10 @@ class OrderChangeManager:
|
|||||||
except BlockedTicketSecret.DoesNotExist:
|
except BlockedTicketSecret.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
# todo: revoke list handling
|
# todo: revoke list handling
|
||||||
|
elif isinstance(op, self.ForceRecomputeOperation):
|
||||||
|
self.order.log_action('pretix.event.order.changed.recomputed', user=self.user, auth=self.auth, data={})
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Unknown operation {type(op)}")
|
||||||
|
|
||||||
for p in secret_dirty:
|
for p in secret_dirty:
|
||||||
assign_ticket_secret(
|
assign_ticket_secret(
|
||||||
@@ -2694,7 +2706,10 @@ class OrderChangeManager:
|
|||||||
fees.append(new_fee)
|
fees.append(new_fee)
|
||||||
|
|
||||||
changed_by_rounding = set(apply_rounding(
|
changed_by_rounding = set(apply_rounding(
|
||||||
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
|
self.order.tax_rounding_mode,
|
||||||
|
self._invoice_address,
|
||||||
|
self.event.currency,
|
||||||
|
[p for p in split_positions if not p.canceled] + fees
|
||||||
))
|
))
|
||||||
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||||
|
|
||||||
@@ -2716,7 +2731,10 @@ class OrderChangeManager:
|
|||||||
fee.delete()
|
fee.delete()
|
||||||
|
|
||||||
changed_by_rounding |= set(apply_rounding(
|
changed_by_rounding |= set(apply_rounding(
|
||||||
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
|
self.order.tax_rounding_mode,
|
||||||
|
self._invoice_address,
|
||||||
|
self.event.currency,
|
||||||
|
[p for p in split_positions if not p.canceled] + fees
|
||||||
))
|
))
|
||||||
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
|
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
@@ -2833,7 +2851,12 @@ class OrderChangeManager:
|
|||||||
if fee_changed:
|
if fee_changed:
|
||||||
fees = list(self.order.fees.all())
|
fees = list(self.order.fees.all())
|
||||||
|
|
||||||
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
|
changed = apply_rounding(
|
||||||
|
self.order.tax_rounding_mode,
|
||||||
|
self._invoice_address,
|
||||||
|
self.order.event.currency,
|
||||||
|
[*positions, *fees]
|
||||||
|
)
|
||||||
for l in changed:
|
for l in changed:
|
||||||
if isinstance(l, OrderPosition):
|
if isinstance(l, OrderPosition):
|
||||||
l.save(update_fields=[
|
l.save(update_fields=[
|
||||||
@@ -3131,7 +3154,10 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
|
|||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
testmode=order.testmode
|
testmode=order.testmode
|
||||||
)
|
)
|
||||||
giftcard.log_action('pretix.giftcards.created', data={})
|
giftcard.log_action(
|
||||||
|
action='pretix.giftcards.created',
|
||||||
|
data={}
|
||||||
|
)
|
||||||
r = order.refunds.create(
|
r = order.refunds.create(
|
||||||
order=order,
|
order=order,
|
||||||
payment=None,
|
payment=None,
|
||||||
@@ -3269,8 +3295,12 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
|||||||
|
|
||||||
positions = list(order.positions.all())
|
positions = list(order.positions.all())
|
||||||
fees = list(order.fees.all())
|
fees = list(order.fees.all())
|
||||||
|
try:
|
||||||
|
ia = order.invoice_address
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
ia = None
|
||||||
rounding_changed = set(apply_rounding(
|
rounding_changed = set(apply_rounding(
|
||||||
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
|
order.tax_rounding_mode, ia, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
|
||||||
))
|
))
|
||||||
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
|
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
|
||||||
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
|
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
|
||||||
@@ -3295,7 +3325,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
|
|||||||
fee = None
|
fee = None
|
||||||
|
|
||||||
rounding_changed |= set(apply_rounding(
|
rounding_changed |= set(apply_rounding(
|
||||||
order.tax_rounding_mode, order.event.currency, [*positions, *fees]
|
order.tax_rounding_mode, ia, order.event.currency, [*positions, *fees]
|
||||||
))
|
))
|
||||||
for l in rounding_changed:
|
for l in rounding_changed:
|
||||||
if isinstance(l, OrderPosition):
|
if isinstance(l, OrderPosition):
|
||||||
@@ -3414,7 +3444,17 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
|
|||||||
currency=sender.currency, issued_in=p, testmode=order.testmode,
|
currency=sender.currency, issued_in=p, testmode=order.testmode,
|
||||||
expires=sender.organizer.default_gift_card_expiry,
|
expires=sender.organizer.default_gift_card_expiry,
|
||||||
)
|
)
|
||||||
gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.created',
|
||||||
|
)
|
||||||
|
trans = gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
data={
|
||||||
|
'value': trans.value,
|
||||||
|
'acceptor_id': order.event.organizer.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
any_giftcards = True
|
any_giftcards = True
|
||||||
p.secret = gc.secret
|
p.secret = gc.secret
|
||||||
p.save(update_fields=['secret'])
|
p.save(update_fields=['secret'])
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
|||||||
|
|
||||||
:param event: Event the cart belongs to
|
:param event: Event the cart belongs to
|
||||||
:param sales_channel: Sales channel the cart was created with
|
:param sales_channel: Sales channel the cart was created with
|
||||||
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
|
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
|
||||||
|
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
|
||||||
|
a better result may be given if addons to the same main product have the same distinct value.
|
||||||
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
||||||
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
|
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
|
||||||
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
|
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
|
||||||
@@ -196,9 +198,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
|||||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||||
for discount in discount_qs:
|
for discount in discount_qs:
|
||||||
result = discount.apply({
|
result = discount.apply({
|
||||||
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
|
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
|
||||||
for
|
for
|
||||||
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
|
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
|
||||||
in enumerate(positions)
|
in enumerate(positions)
|
||||||
if not is_bundled and idx not in new_prices
|
if not is_bundled and idx not in new_prices
|
||||||
}, collect_potential_discounts)
|
}, collect_potential_discounts)
|
||||||
@@ -209,7 +211,8 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
|||||||
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
||||||
|
|
||||||
|
|
||||||
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
|
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"],
|
||||||
|
invoice_address: Optional[InvoiceAddress], currency: str,
|
||||||
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
|
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
|
||||||
"""
|
"""
|
||||||
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
|
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
|
||||||
@@ -224,11 +227,17 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep
|
|||||||
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
|
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
|
||||||
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
|
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
|
||||||
|
|
||||||
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
|
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``.
|
||||||
|
:param invoice_address: The invoice address, or ``None``
|
||||||
:param currency: Currency that will be used to determine rounding precision
|
:param currency: Currency that will be used to determine rounding precision
|
||||||
:param lines: List of order/cart contents
|
:param lines: List of order/cart contents
|
||||||
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
|
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
|
||||||
"""
|
"""
|
||||||
|
if rounding_mode == "sum_by_net_only_business":
|
||||||
|
if invoice_address and invoice_address.is_business:
|
||||||
|
rounding_mode = "sum_by_net"
|
||||||
|
else:
|
||||||
|
rounding_mode = "line"
|
||||||
|
|
||||||
def _key(line):
|
def _key(line):
|
||||||
return (line.tax_rate, line.tax_code or "")
|
return (line.tax_rate, line.tax_code or "")
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.services.tasks import ProfiledEventTask
|
from pretix.base.services.tasks import ProfiledEventTask
|
||||||
from pretix.base.shredder import ShredError
|
from pretix.base.shredder import ShredError
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
@@ -171,21 +171,18 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
with language(user.locale):
|
with language(user.locale):
|
||||||
try:
|
mail(
|
||||||
mail(
|
user.email,
|
||||||
user.email,
|
_('Data shredding completed'),
|
||||||
_('Data shredding completed'),
|
'pretixbase/email/shred_completed.txt',
|
||||||
'pretixbase/email/shred_completed.txt',
|
{
|
||||||
{
|
'user': user,
|
||||||
'user': user,
|
'organizer': event.organizer.name,
|
||||||
'organizer': event.organizer.name,
|
'event': str(event.name),
|
||||||
'event': str(event.name),
|
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
||||||
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
||||||
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
user=user,
|
||||||
user=user,
|
locale=user.locale,
|
||||||
locale=user.locale,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ def dictsum(*dicts) -> dict:
|
|||||||
|
|
||||||
def order_overview(
|
def order_overview(
|
||||||
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
||||||
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None
|
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None,
|
||||||
|
skip_empty_lines=False,
|
||||||
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||||
items = event.items.all().select_related(
|
items = event.items.all().select_related(
|
||||||
'category', # for re-grouping
|
'category', # for re-grouping
|
||||||
@@ -205,13 +206,21 @@ def order_overview(
|
|||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
|
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
|
||||||
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
|
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
|
||||||
|
var._skip = all(v[0] == 0 for v in var.num.values())
|
||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
|
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
|
||||||
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
|
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
|
||||||
|
if skip_empty_lines:
|
||||||
|
item.all_variations = [v for v in item.all_variations if not v._skip]
|
||||||
|
item._skip = not item.all_variations
|
||||||
else:
|
else:
|
||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
|
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
|
||||||
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
|
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
|
||||||
|
item._skip = all(v[0] == 0 for v in item.num.values())
|
||||||
|
|
||||||
|
if skip_empty_lines:
|
||||||
|
items = [i for i in items if not i._skip]
|
||||||
|
|
||||||
nonecat = ItemCategory(name=_('Uncategorized'))
|
nonecat = ItemCategory(name=_('Uncategorized'))
|
||||||
# Regroup those by category
|
# Regroup those by category
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ from pretix.base.validators import multimail_validate
|
|||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||||
)
|
)
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries, pycountry_add
|
||||||
|
|
||||||
ROUNDING_MODES = (
|
ROUNDING_MODES = (
|
||||||
('line', _('Compute taxes for every line individually')),
|
('line', _('Compute taxes for every line individually')),
|
||||||
('sum_by_net', _('Compute taxes based on net total')),
|
('sum_by_net', _('Compute taxes based on net total')),
|
||||||
|
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
|
||||||
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
|
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
|
||||||
# We could also have sum_by_gross, but we're not aware of any use-cases for it
|
# We could also have sum_by_gross, but we're not aware of any use-cases for it
|
||||||
)
|
)
|
||||||
@@ -180,6 +181,19 @@ DEFAULTS = {
|
|||||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'customer_accounts_require_login_for_order_access': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Require login to access order confirmation pages"),
|
||||||
|
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
|
||||||
|
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
|
||||||
|
"account is activated."),
|
||||||
|
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
||||||
|
)
|
||||||
|
},
|
||||||
'customer_accounts_link_by_email': {
|
'customer_accounts_link_by_email': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -1212,6 +1226,15 @@ DEFAULTS = {
|
|||||||
'default': json.dumps(['web']),
|
'default': json.dumps(['web']),
|
||||||
'type': list
|
'type': list
|
||||||
},
|
},
|
||||||
|
'invoice_generate_only_business': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Only issue invoices to business customers"),
|
||||||
|
)
|
||||||
|
},
|
||||||
'invoice_address_from': {
|
'invoice_address_from': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': str,
|
'type': str,
|
||||||
@@ -2925,6 +2948,28 @@ If you did not request a new password, please ignore this email.
|
|||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
|
|
||||||
|
Your {organizer} team""")) # noqa: W291
|
||||||
|
},
|
||||||
|
'mail_subject_customer_security_notice': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
|
||||||
|
},
|
||||||
|
'mail_text_customer_security_notice': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||||
|
|
||||||
|
the following change has been made to your account at {organizer}:
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
You can review and change your account settings here:
|
||||||
|
|
||||||
|
{url}
|
||||||
|
|
||||||
|
If this change was not performed by you, please contact us immediately.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
|
||||||
Your {organizer} team""")) # noqa: W291
|
Your {organizer} team""")) # noqa: W291
|
||||||
},
|
},
|
||||||
'smtp_use_custom': {
|
'smtp_use_custom': {
|
||||||
@@ -3914,7 +3959,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
|||||||
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
|
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
|
||||||
'US': (['State', 'Outlying area', 'District'], 'short'),
|
'US': (['State', 'Outlying area', 'District'], 'short'),
|
||||||
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
|
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
|
||||||
'Free municipal consortium', 'Decentralized regional entity'], 'short'),
|
'Decentralized regional entity'], 'short'),
|
||||||
}
|
}
|
||||||
COUNTRY_STATE_LABEL = {
|
COUNTRY_STATE_LABEL = {
|
||||||
# Countries in which the "State" field should not be called "State"
|
# Countries in which the "State" field should not be called "State"
|
||||||
@@ -3922,6 +3967,8 @@ COUNTRY_STATE_LABEL = {
|
|||||||
'JP': pgettext_lazy('address', 'Prefecture'),
|
'JP': pgettext_lazy('address', 'Prefecture'),
|
||||||
'IT': pgettext_lazy('address', 'Province'),
|
'IT': pgettext_lazy('address', 'Province'),
|
||||||
}
|
}
|
||||||
|
# Workaround for https://github.com/pretix/pretix/issues/5796
|
||||||
|
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
|
||||||
|
|
||||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
|
|||||||
from pretix.base.i18n import LazyLocaleException
|
from pretix.base.i18n import LazyLocaleException
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
||||||
OrderPosition, OrderRefund, QuestionAnswer,
|
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
|
||||||
)
|
)
|
||||||
from pretix.base.services.invoices import invoice_pdf_task
|
from pretix.base.services.invoices import invoice_pdf_task
|
||||||
from pretix.base.signals import register_data_shredders
|
from pretix.base.signals import register_data_shredders
|
||||||
@@ -329,6 +329,10 @@ class EmailAddressShredder(BaseDataShredder):
|
|||||||
sleep_time=2,
|
sleep_time=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
slow_delete(
|
||||||
|
OutgoingMail.objects.filter(event=self.event)
|
||||||
|
)
|
||||||
|
|
||||||
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
|
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
|
||||||
changed = bool(o.email) or bool(o.customer)
|
changed = bool(o.email) or bool(o.customer)
|
||||||
o.email = None
|
o.email = None
|
||||||
|
|||||||
@@ -944,32 +944,40 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
|
|||||||
|
|
||||||
email_filter = EventPluginSignal()
|
email_filter = EventPluginSignal()
|
||||||
"""
|
"""
|
||||||
Arguments: ``message``, ``order``, ``user``
|
Arguments: ``message``, ``order``, ``user``, ``outgoing_mail``
|
||||||
|
|
||||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||||
return a (possibly modified) copy of the message object passed to you.
|
return a (possibly modified) copy of the message object passed to you.
|
||||||
|
|
||||||
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.
|
||||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||||
|
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||||
|
might have newer information if a previous plugin already modified the email.
|
||||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||||
it will be ``None``.
|
it will be ``None``.
|
||||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||||
well, otherwise it will be ``None``.
|
well, otherwise it will be ``None``.
|
||||||
|
|
||||||
|
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
global_email_filter = GlobalSignal()
|
global_email_filter = GlobalSignal()
|
||||||
"""
|
"""
|
||||||
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``
|
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail``
|
||||||
|
|
||||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||||
return a (possibly modified) copy of the message object passed to you.
|
return a (possibly modified) copy of the message object passed to you.
|
||||||
|
|
||||||
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
||||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||||
|
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||||
|
might have newer information if a previous plugin already modified the email.
|
||||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||||
it will be ``None``.
|
it will be ``None``.
|
||||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||||
well, otherwise it will be ``None``.
|
well, otherwise it will be ``None``.
|
||||||
|
|
||||||
|
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
<h1>{% trans "Not found" %}</h1>
|
<h1>{% trans "Not found" %}</h1>
|
||||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||||
<p>{{ exception }}</p>
|
<p>{{ exception }}</p>
|
||||||
<p class="links">
|
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
|
||||||
</p>
|
|
||||||
{% if request.user.is_staff and not staff_session %}
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ from babel.numbers import format_currency
|
|||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.utils import translation
|
|
||||||
|
from pretix.base.i18n import get_babel_locale
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
|||||||
if hide_currency:
|
if hide_currency:
|
||||||
return floatformat(value, f"{places}g")
|
return floatformat(value, f"{places}g")
|
||||||
|
|
||||||
locale_parts = translation.get_language().split("-", 1)
|
try:
|
||||||
locale = locale_parts[0]
|
locale = Locale(get_babel_locale())
|
||||||
if len(locale_parts) > 1 and len(locale_parts[1]) == 2:
|
except UnknownLocaleError:
|
||||||
try:
|
locale = "en"
|
||||||
locale = Locale(locale_parts[0], locale_parts[1].upper())
|
|
||||||
except UnknownLocaleError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return format_currency(value, arg, locale=locale)
|
return format_currency(value, arg, locale=locale)
|
||||||
|
|||||||
@@ -32,13 +32,14 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import html
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
import markdown
|
import markdown
|
||||||
from bleach import DEFAULT_CALLBACKS
|
from bleach import DEFAULT_CALLBACKS, html5lib_shim
|
||||||
from bleach.linkifier import build_email_re, build_url_re
|
from bleach.linkifier import build_email_re
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
@@ -124,6 +125,23 @@ ALLOWED_ATTRIBUTES = {
|
|||||||
|
|
||||||
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
||||||
|
|
||||||
|
|
||||||
|
def build_url_re(tlds=tld_set, protocols=html5lib_shim.allowed_protocols):
|
||||||
|
# Differs from bleach regex by allowing { and } in URL to allow placeholders in URL parameters
|
||||||
|
return re.compile(
|
||||||
|
r"""\(* # Match any opening parentheses.
|
||||||
|
\b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
|
||||||
|
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
|
||||||
|
(?:[/?][^\s\|\\\^`<>"]*)?
|
||||||
|
# /path/zz (excluding "unsafe" chars from RFC 3986,
|
||||||
|
# except for # and ~, which happen in practice)
|
||||||
|
""".format(
|
||||||
|
"|".join(sorted(protocols)), "|".join(sorted(tlds))
|
||||||
|
),
|
||||||
|
re.IGNORECASE | re.VERBOSE | re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
||||||
|
|
||||||
EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
||||||
@@ -138,7 +156,7 @@ def safelink_callback(attrs, new=False):
|
|||||||
Makes sure that all links to a different domain are passed through a redirection handler
|
Makes sure that all links to a different domain are passed through a redirection handler
|
||||||
to ensure there's no passing of referers with secrets inside them.
|
to ensure there's no passing of referers with secrets inside them.
|
||||||
"""
|
"""
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = html.unescape(attrs.get((None, 'href'), '/'))
|
||||||
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
signer = signing.Signer(salt='safe-redirect')
|
||||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||||
@@ -333,8 +351,14 @@ def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED
|
|||||||
# This is a workaround to fix placeholders in URL targets
|
# This is a workaround to fix placeholders in URL targets
|
||||||
def context_callback(attrs, new=False):
|
def context_callback(attrs, new=False):
|
||||||
if (None, "href") in attrs and "{" in attrs[None, "href"]:
|
if (None, "href") in attrs and "{" in attrs[None, "href"]:
|
||||||
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification
|
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification.
|
||||||
attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
|
# We want to esacpe the end result, however, we need to unescape the input to prevent & being turned
|
||||||
|
# to &amp; because the input is already escaped by the markdown parser.
|
||||||
|
attrs[None, "href"] = escape(format_map(
|
||||||
|
html.unescape(attrs[None, "href"]),
|
||||||
|
context=context,
|
||||||
|
mode=SafeFormatter.MODE_RICH_TO_PLAIN
|
||||||
|
))
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
context_callbacks.append(context_callback)
|
context_callbacks.append(context_callback)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
|
|||||||
'csp_report',
|
'csp_report',
|
||||||
'widget',
|
'widget',
|
||||||
'lead',
|
'lead',
|
||||||
|
'scheduling',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
import pycountry
|
import pycountry
|
||||||
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext, pgettext, pgettext_lazy
|
from django.utils.translation import gettext, pgettext, pgettext_lazy
|
||||||
@@ -29,6 +30,7 @@ from django_scopes import scope
|
|||||||
from pretix.base.addressvalidation import (
|
from pretix.base.addressvalidation import (
|
||||||
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
|
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
|
||||||
)
|
)
|
||||||
|
from pretix.base.i18n import language
|
||||||
from pretix.base.invoicing.transmission import get_transmission_types
|
from pretix.base.invoicing.transmission import get_transmission_types
|
||||||
from pretix.base.models import Organizer
|
from pretix.base.models import Organizer
|
||||||
from pretix.base.models.tax import VAT_ID_COUNTRIES
|
from pretix.base.models.tax import VAT_ID_COUNTRIES
|
||||||
@@ -82,14 +84,14 @@ def _info(cc):
|
|||||||
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
|
||||||
return {
|
return {
|
||||||
'data': [
|
'data': [
|
||||||
{'name': s.name, 'code': s.code[3:]}
|
{'name': gettext(s.name), 'code': s.code[3:]}
|
||||||
for s in sorted(statelist, key=lambda s: s.name)
|
for s in sorted(statelist, key=lambda s: s.name)
|
||||||
],
|
],
|
||||||
**info,
|
**info,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def address_form(request):
|
def _address_form(request):
|
||||||
cc = request.GET.get("country", "DE")
|
cc = request.GET.get("country", "DE")
|
||||||
info = _info(cc)
|
info = _info(cc)
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ def address_form(request):
|
|||||||
for t in get_transmission_types():
|
for t in get_transmission_types():
|
||||||
if t.is_available(event=event, country=country, is_business=is_business):
|
if t.is_available(event=event, country=country, is_business=is_business):
|
||||||
result = {"name": str(t.public_name), "code": t.identifier}
|
result = {"name": str(t.public_name), "code": t.identifier}
|
||||||
if t.exclusive:
|
if t.is_exclusive(event=event, country=country, is_business=is_business):
|
||||||
info["transmission_types"] = [result]
|
info["transmission_types"] = [result]
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -157,4 +159,15 @@ def address_form(request):
|
|||||||
# The help text explains that it is optional, so we want to hide that if it is required
|
# The help text explains that it is optional, so we want to hide that if it is required
|
||||||
info["vat_id"]["helptext_visible"] = False
|
info["vat_id"]["helptext_visible"] = False
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def address_form(request):
|
||||||
|
locale = request.GET.get('locale')
|
||||||
|
if locale in dict(settings.LANGUAGES):
|
||||||
|
with language(locale):
|
||||||
|
info = _address_form(request)
|
||||||
|
else:
|
||||||
|
info = _address_form(request)
|
||||||
|
|
||||||
return JsonResponse(info)
|
return JsonResponse(info)
|
||||||
|
|||||||
@@ -867,6 +867,11 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
|||||||
"The gross price of some products may be changed to ensure correct rounding, while the net "
|
"The gross price of some products may be changed to ensure correct rounding, while the net "
|
||||||
"prices will be kept as configured. This may cause the actual payment amount to differ."
|
"prices will be kept as configured. This may cause the actual payment amount to differ."
|
||||||
),
|
),
|
||||||
|
"sum_by_net_only_business": _(
|
||||||
|
"Same as above, but only applied to business customers. Line-based rounding will be used for consumers. "
|
||||||
|
"Recommended when e-invoicing is only used for business customers and consumers do not receive "
|
||||||
|
"invoices. This can cause the payment amount to change when the invoice address is changed."
|
||||||
|
),
|
||||||
"sum_by_net_keep_gross": _(
|
"sum_by_net_keep_gross": _(
|
||||||
"Recommended for e-invoicing when you primarily sell to consumers. "
|
"Recommended for e-invoicing when you primarily sell to consumers. "
|
||||||
"The gross or net price of some products may be changed automatically to ensure correct "
|
"The gross or net price of some products may be changed automatically to ensure correct "
|
||||||
@@ -939,6 +944,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
|||||||
'invoice_show_payments',
|
'invoice_show_payments',
|
||||||
'invoice_reissue_after_modify',
|
'invoice_reissue_after_modify',
|
||||||
'invoice_generate',
|
'invoice_generate',
|
||||||
|
'invoice_generate_only_business',
|
||||||
'invoice_period',
|
'invoice_period',
|
||||||
'invoice_attendee_name',
|
'invoice_attendee_name',
|
||||||
'invoice_event_location',
|
'invoice_event_location',
|
||||||
|
|||||||
@@ -57,8 +57,9 @@ from pretix.base.forms.widgets import (
|
|||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
|
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
|
||||||
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
|
||||||
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel,
|
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
|
||||||
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
|
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
|
||||||
|
Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.signals import register_payment_providers
|
from pretix.base.signals import register_payment_providers
|
||||||
from pretix.base.timeframes import (
|
from pretix.base.timeframes import (
|
||||||
@@ -1315,10 +1316,10 @@ class QuestionAnswerFilterForm(forms.Form):
|
|||||||
|
|
||||||
if date_range is not None:
|
if date_range is not None:
|
||||||
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
|
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
|
||||||
opqs = opqs.filter(
|
if d_start:
|
||||||
subevent__date_from__gte=d_start,
|
opqs = opqs.filter(subevent__date_from__gte=d_start)
|
||||||
subevent__date_from__lt=d_end
|
if d_end:
|
||||||
)
|
opqs = opqs.filter(subevent__date_from__lt=d_end)
|
||||||
|
|
||||||
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
|
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
|
||||||
if s != "":
|
if s != "":
|
||||||
@@ -2815,3 +2816,61 @@ class DeviceFilterForm(FilterForm):
|
|||||||
qs = qs.order_by('-device_id')
|
qs = qs.order_by('-device_id')
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMailFilterForm(FilterForm):
|
||||||
|
orders = {
|
||||||
|
'date': 'created',
|
||||||
|
'-date': '-created',
|
||||||
|
}
|
||||||
|
query = forms.CharField(
|
||||||
|
label=_('Search email address or subject'),
|
||||||
|
widget=forms.TextInput(attrs={
|
||||||
|
'placeholder': _('Search email address or subject'),
|
||||||
|
}),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
event = forms.ModelChoiceField(
|
||||||
|
queryset=Event.objects.none(),
|
||||||
|
label=_('Event'),
|
||||||
|
empty_label=_('All events'),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
status = forms.ChoiceField(
|
||||||
|
label=_('Status'),
|
||||||
|
choices=[
|
||||||
|
('', _('All')),
|
||||||
|
*OutgoingMail.STATUS_CHOICES,
|
||||||
|
],
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
request = kwargs.pop('request')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['event'].queryset = request.organizer.events.all()
|
||||||
|
|
||||||
|
def filter_qs(self, qs):
|
||||||
|
fdata = self.cleaned_data
|
||||||
|
|
||||||
|
if fdata.get('query'):
|
||||||
|
query = fdata.get('query')
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(to__containsstring=query.lower())
|
||||||
|
| Q(cc__containsstring=query.lower())
|
||||||
|
| Q(bcc__containsstring=query.lower())
|
||||||
|
| Q(subject__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
if fdata.get('event'):
|
||||||
|
qs = qs.filter(event=fdata['event'])
|
||||||
|
|
||||||
|
if fdata.get('status'):
|
||||||
|
qs = qs.filter(status=fdata['status'])
|
||||||
|
|
||||||
|
if fdata.get('ordering'):
|
||||||
|
qs = qs.order_by(self.get_order_by())
|
||||||
|
else:
|
||||||
|
qs = qs.order_by("-created", "-pk")
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm):
|
|||||||
'customer_accounts',
|
'customer_accounts',
|
||||||
'customer_accounts_native',
|
'customer_accounts_native',
|
||||||
'customer_accounts_link_by_email',
|
'customer_accounts_link_by_email',
|
||||||
|
'customer_accounts_require_login_for_order_access',
|
||||||
'invoice_regenerate_allowed',
|
'invoice_regenerate_allowed',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
@@ -584,6 +585,7 @@ class MailSettingsForm(SettingsForm):
|
|||||||
help_text=''.join([
|
help_text=''.join([
|
||||||
str(_("All emails will be sent to this address as a Bcc copy.")),
|
str(_("All emails will be sent to this address as a Bcc copy.")),
|
||||||
str(_("You can specify multiple recipients separated by commas.")),
|
str(_("You can specify multiple recipients separated by commas.")),
|
||||||
|
str(_("Sensitive emails like password resets will not be sent in Bcc.")),
|
||||||
]),
|
]),
|
||||||
validators=[multimail_validate],
|
validators=[multimail_validate],
|
||||||
required=False,
|
required=False,
|
||||||
@@ -633,6 +635,16 @@ class MailSettingsForm(SettingsForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=I18nMarkdownTextarea,
|
widget=I18nMarkdownTextarea,
|
||||||
)
|
)
|
||||||
|
mail_subject_customer_security_notice = I18nFormField(
|
||||||
|
label=_("Subject"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nTextInput,
|
||||||
|
)
|
||||||
|
mail_text_customer_security_notice = I18nFormField(
|
||||||
|
label=_("Text"),
|
||||||
|
required=False,
|
||||||
|
widget=I18nMarkdownTextarea,
|
||||||
|
)
|
||||||
|
|
||||||
base_context = {
|
base_context = {
|
||||||
'mail_text_customer_registration': ['customer', 'url'],
|
'mail_text_customer_registration': ['customer', 'url'],
|
||||||
@@ -641,6 +653,8 @@ class MailSettingsForm(SettingsForm):
|
|||||||
'mail_subject_customer_email_change': ['customer', 'url'],
|
'mail_subject_customer_email_change': ['customer', 'url'],
|
||||||
'mail_text_customer_reset': ['customer', 'url'],
|
'mail_text_customer_reset': ['customer', 'url'],
|
||||||
'mail_subject_customer_reset': ['customer', 'url'],
|
'mail_subject_customer_reset': ['customer', 'url'],
|
||||||
|
'mail_text_customer_security_notice': ['customer', 'url', 'message'],
|
||||||
|
'mail_subject_customer_security_notice': ['customer', 'url', 'message'],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_sample_context(self, base_parameters):
|
def _get_sample_context(self, base_parameters):
|
||||||
@@ -654,6 +668,9 @@ class MailSettingsForm(SettingsForm):
|
|||||||
'presale:organizer.customer.activate'
|
'presale:organizer.customer.activate'
|
||||||
) + '?token=' + get_random_string(30)
|
) + '?token=' + get_random_string(30)
|
||||||
|
|
||||||
|
if 'message' in base_parameters:
|
||||||
|
placeholders['message'] = _('Your password has been changed.')
|
||||||
|
|
||||||
if 'customer' in base_parameters:
|
if 'customer' in base_parameters:
|
||||||
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
|
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
|
||||||
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ class OrderFeeAdded(OrderChangeLogEntryType):
|
|||||||
plain = _('A fee has been added')
|
plain = _('A fee has been added')
|
||||||
|
|
||||||
|
|
||||||
|
@log_entry_types.new()
|
||||||
|
class OrderRecomputed(OrderChangeLogEntryType):
|
||||||
|
action_type = 'pretix.event.order.changed.recomputed'
|
||||||
|
plain = _('Taxes and rounding have been recomputed')
|
||||||
|
|
||||||
|
|
||||||
@log_entry_types.new()
|
@log_entry_types.new()
|
||||||
class OrderFeeChanged(OrderChangeLogEntryType):
|
class OrderFeeChanged(OrderChangeLogEntryType):
|
||||||
action_type = 'pretix.event.order.changed.feevalue'
|
action_type = 'pretix.event.order.changed.feevalue'
|
||||||
@@ -699,6 +705,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
|||||||
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
|
||||||
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
|
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||||
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||||
|
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
|
||||||
|
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
|
||||||
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
|
||||||
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
|
||||||
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
|
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
|
||||||
@@ -793,6 +801,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
|||||||
'pretix.giftcards.created': _('The gift card has been created.'),
|
'pretix.giftcards.created': _('The gift card has been created.'),
|
||||||
'pretix.giftcards.modified': _('The gift card has been changed.'),
|
'pretix.giftcards.modified': _('The gift card has been changed.'),
|
||||||
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
|
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
|
||||||
|
'pretix.giftcards.transaction.payment': _('A payment has been performed.'),
|
||||||
|
'pretix.giftcards.transaction.refund': _('A refund has been performed. '),
|
||||||
'pretix.team.token.created': _('The token "{name}" has been created.'),
|
'pretix.team.token.created': _('The token "{name}" has been created.'),
|
||||||
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
|
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
|
||||||
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
|
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
|
||||||
@@ -814,7 +824,7 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
|||||||
if app and hasattr(app, 'PretixPluginMeta'):
|
if app and hasattr(app, 'PretixPluginMeta'):
|
||||||
return {
|
return {
|
||||||
'href': reverse('control:organizer.settings.plugins', kwargs={
|
'href': reverse('control:organizer.settings.plugins', kwargs={
|
||||||
'organizer': logentry.event.organizer.slug,
|
'organizer': logentry.organizer.slug,
|
||||||
}) + '#plugin_' + logentry.parsed_data['plugin'],
|
}) + '#plugin_' + logentry.parsed_data['plugin'],
|
||||||
'val': app.PretixPluginMeta.name
|
'val': app.PretixPluginMeta.name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -679,6 +679,15 @@ def get_organizer_navigation(request):
|
|||||||
'active': (url.url_name == 'organizer.datasync.failedjobs'),
|
'active': (url.url_name == 'organizer.datasync.failedjobs'),
|
||||||
}])
|
}])
|
||||||
|
|
||||||
|
nav.append({
|
||||||
|
'label': _('Outgoing emails'),
|
||||||
|
'url': reverse('control:organizer.outgoingmails', kwargs={
|
||||||
|
'organizer': request.organizer.slug,
|
||||||
|
}),
|
||||||
|
'active': 'organizer.outgoingmail' in url.url_name,
|
||||||
|
'icon': 'send',
|
||||||
|
})
|
||||||
|
|
||||||
merge_in(nav, sorted(
|
merge_in(nav, sorted(
|
||||||
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
||||||
[]),
|
[]),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
|
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
|
||||||
</div>
|
</div>
|
||||||
{{ f.value_map.as_hidden }}
|
{{ formset.empty_form.value_map.as_hidden }}
|
||||||
<div class="col-md-2 text-right flip">
|
<div class="col-md-2 text-right flip">
|
||||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<legend>{% trans "Invoice generation" %}</legend>
|
<legend>{% trans "Invoice generation" %}</legend>
|
||||||
{% bootstrap_field form.invoice_generate layout="control" %}
|
{% bootstrap_field form.invoice_generate layout="control" %}
|
||||||
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
|
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
|
||||||
|
{% bootstrap_field form.invoice_generate_only_business layout="control" %}
|
||||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||||
{% bootstrap_field form.invoice_email_organizer layout="control" %}
|
{% bootstrap_field form.invoice_email_organizer layout="control" %}
|
||||||
{% bootstrap_field form.invoice_language layout="control" %}
|
{% bootstrap_field form.invoice_language layout="control" %}
|
||||||
@@ -111,11 +112,6 @@
|
|||||||
<span class="text-success">
|
<span class="text-success">
|
||||||
<span class="fa fa-check fa-fw"></span>
|
<span class="fa fa-check fa-fw"></span>
|
||||||
{% trans "Available" %}
|
{% trans "Available" %}
|
||||||
{% if t.exclusive %}
|
|
||||||
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
|
|
||||||
{% trans "(exclusive)" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">
|
<span class="text-muted">
|
||||||
|
|||||||
@@ -353,7 +353,7 @@
|
|||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
|
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{% if order.status == "c" %}
|
{% if order.status == "c" or not invoice_qualified %}
|
||||||
{% trans "Generate cancellation" %}
|
{% trans "Generate cancellation" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% trans "Cancel and reissue" %}
|
{% trans "Cancel and reissue" %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{{ s.owner.fullname|default:s.owner.email }}
|
{{ s.owner.fullname|default:s.owner.email }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
<div class="col-lg-4 col-md-5 col-xs-12">
|
||||||
{% if s.schedule_next_run %}
|
{% if s.schedule_next_run %}
|
||||||
<span class="fa fa-clock-o fa-fw"></span>
|
<span class="fa fa-clock-o fa-fw"></span>
|
||||||
{% trans "Next run:" %}
|
{% trans "Next run:" %}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
{{ s.mail_subject }}
|
{{ s.mail_subject }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
|
||||||
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
|
||||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||||
data-asynctask-long>
|
data-asynctask-long>
|
||||||
@@ -73,6 +73,9 @@
|
|||||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||||
<span class="fa fa-edit"></span>
|
<span class="fa fa-edit"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
|
||||||
|
<span class="fa fa-copy"></span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||||
<span class="fa fa-trash"></span>
|
<span class="fa fa-trash"></span>
|
||||||
|
|||||||
@@ -42,7 +42,11 @@
|
|||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||||
class="btn btn-primary btn-save" data-no-asynctask>
|
class="btn btn-primary btn-save" data-no-asynctask>
|
||||||
{% trans "Save" %}
|
{% if scheduled_copy_from %}
|
||||||
|
{% trans "Save copy" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Save" %}
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@
|
|||||||
<legend>{% trans "Customer accounts" %}</legend>
|
<legend>{% trans "Customer accounts" %}</legend>
|
||||||
{% bootstrap_field sform.customer_accounts layout="control" %}
|
{% bootstrap_field sform.customer_accounts layout="control" %}
|
||||||
{% bootstrap_field sform.customer_accounts_native layout="control" %}
|
{% bootstrap_field sform.customer_accounts_native layout="control" %}
|
||||||
|
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
|
||||||
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
|
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
|
||||||
{% bootstrap_field sform.name_scheme layout="control" %}
|
{% bootstrap_field sform.name_scheme layout="control" %}
|
||||||
{% bootstrap_field sform.name_scheme_titles layout="control" %}
|
{% bootstrap_field sform.name_scheme_titles layout="control" %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{{ s.owner.fullname|default:s.owner.email }}
|
{{ s.owner.fullname|default:s.owner.email }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-5 col-md-6 col-xs-12">
|
<div class="col-lg-4 col-md-5 col-xs-12">
|
||||||
{% if s.schedule_next_run %}
|
{% if s.schedule_next_run %}
|
||||||
<span class="fa fa-clock-o fa-fw"></span>
|
<span class="fa fa-clock-o fa-fw"></span>
|
||||||
{% trans "Next run:" %}
|
{% trans "Next run:" %}
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
{{ s.mail_subject }}
|
{{ s.mail_subject }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
|
<div class="col-lg-3 col-md-3 col-xs-12 text-right">
|
||||||
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
|
||||||
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
method="post" class="form-horizontal" data-asynctask data-asynctask-download
|
||||||
data-asynctask-long>
|
data-asynctask-long>
|
||||||
@@ -73,6 +73,9 @@
|
|||||||
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
|
||||||
<span class="fa fa-edit"></span>
|
<span class="fa fa-edit"></span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
|
||||||
|
<span class="fa fa-copy"></span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
|
||||||
<span class="fa fa-trash"></span>
|
<span class="fa fa-trash"></span>
|
||||||
|
|||||||
@@ -43,7 +43,11 @@
|
|||||||
<div class="form-group submit-group">
|
<div class="form-group submit-group">
|
||||||
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
|
||||||
class="btn btn-primary btn-save" data-no-asynctask>
|
class="btn btn-primary btn-save" data-no-asynctask>
|
||||||
{% trans "Save" %}
|
{% if scheduled_copy_from %}
|
||||||
|
{% trans "Save copy" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Save" %}
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
|
|
||||||
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
|
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
|
||||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
|
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
|
||||||
|
|
||||||
|
{% blocktrans asvar title_security_notice %}Customer account security notification{% endblocktrans %}
|
||||||
|
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="security_notice" title=title_security_notice items="mail_subject_customer_security_notice,mail_text_customer_security_notice" %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load icon %}
|
||||||
|
{% load compress %}
|
||||||
|
{% load static %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% trans "Outgoing email" %}
|
||||||
|
</h1>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">{% trans "Email details" %}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-7 col-md-12">
|
||||||
|
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>{% trans "From" context "email" %}</dt>
|
||||||
|
<dd>{{ sender }}</dd>
|
||||||
|
<dt>{% trans "To" context "email" %}</dt>
|
||||||
|
<dd>{{ mail.to|join:", " }}</dd>
|
||||||
|
{% if mail.cc %}
|
||||||
|
<dt>{% trans "Cc" context "email" %}</dt>
|
||||||
|
<dd>{{ mail.cc|join:", " }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.bcc %}
|
||||||
|
<dt>{% trans "Bcc" context "email" %}</dt>
|
||||||
|
<dd>{{ mail.bcc|join:", " }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt>{% trans "Subject" %}</dt>
|
||||||
|
<dd>{{ mail.subject }}</dd>
|
||||||
|
<dt>{% trans "Status" %}</dt>
|
||||||
|
<dd>
|
||||||
|
{% if mail.status == "queued" %}
|
||||||
|
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
|
||||||
|
{% elif mail.status == "inflight" %}
|
||||||
|
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
|
||||||
|
{% elif mail.status == "awaiting_retry" %}
|
||||||
|
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
|
||||||
|
{% elif mail.status == "failed" %}
|
||||||
|
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
|
||||||
|
{% elif mail.status == "bounced" %}
|
||||||
|
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
|
||||||
|
{% elif mail.status == "withheld" %}
|
||||||
|
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
|
||||||
|
{% elif mail.status == "aborted" %}
|
||||||
|
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
|
||||||
|
{% elif mail.status == "sent" %}
|
||||||
|
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>{% trans "Creation" %}</dt>
|
||||||
|
<dd>{{ mail.created|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||||
|
{% if mail.sent %}
|
||||||
|
<dt>{% trans "Sent" %}</dt>
|
||||||
|
<dd>{{ mail.sent|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.retry_after and mail.status == "awaiting_retry" %}
|
||||||
|
<dt>{% trans "Next attempt (estimate)" %}</dt>
|
||||||
|
<dd>{{ mail.retry_after|date:"SHORT_DATETIME_FORMAT" }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.event %}
|
||||||
|
<dt>{% trans "Event" %}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href="{% url "control:event.index" organizer=request.organizer.slug event=mail.event.slug %}">
|
||||||
|
{{ mail.event }}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.order %}
|
||||||
|
<dt>{% trans "Order" %}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href="{% url "control:event.order" organizer=request.organizer.slug event=mail.event.slug code=mail.order.code %}">
|
||||||
|
{{ mail.order.code }}</a>{% if mail.orderposition %}-
|
||||||
|
{{ mail.orderposition.positionid }}{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.customer %}
|
||||||
|
<dt>{% trans "Customer" %}</dt>
|
||||||
|
<dd>
|
||||||
|
{% icon "user fa-fw" %}
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=mail.customer.identifier %}">
|
||||||
|
{{ mail.customer }}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% if mail.actual_attachments %}
|
||||||
|
<div class="col-lg-5 col-md-12">
|
||||||
|
<strong>{% trans "Attachments" %}</strong><br>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
{% for a in mail.actual_attachments %}
|
||||||
|
<li>
|
||||||
|
{% if a.type == "text/calendar" %}
|
||||||
|
{% icon "calendar-plus-o fa-fw" %}
|
||||||
|
{% elif a.type == "application/pdf" %}
|
||||||
|
{% icon "file-pdf-o fa-fw" %}
|
||||||
|
{% elif "image/" in a.type %}
|
||||||
|
{% icon "file-image-o fa-fw" %}
|
||||||
|
{% elif "msword" in a.type or "document" in a.type %}
|
||||||
|
{% icon "file-word-o fa-fw" %}
|
||||||
|
{% elif "excel" in a.type or "spreadsheet" in a.type %}
|
||||||
|
{% icon "file-excel-o fa-fw" %}
|
||||||
|
{% elif "powerpoint" in a.type or "presentation" in a.type %}
|
||||||
|
{% icon "file-powerpoint-o fa-fw" %}
|
||||||
|
{% elif "pkpass" in a.type %}
|
||||||
|
{% icon "qrcode fa-fw" %}
|
||||||
|
{% else %}
|
||||||
|
{% icon "file-o fa-fw" %}
|
||||||
|
{% endif %}
|
||||||
|
{{ a.name }}
|
||||||
|
<span class="text-muted">
|
||||||
|
({{ a.size|filesizeformat }})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul class="nav nav-tabs" role="tablist">
|
||||||
|
{% if mail.is_failed %}
|
||||||
|
<li role="presentation" class="active">
|
||||||
|
<a href="#tab-error" role="tab" data-toggle="tab">
|
||||||
|
<span class="fa fa-warning"></span>
|
||||||
|
{% trans "Error" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.body_html %}
|
||||||
|
<li role="presentation"
|
||||||
|
{% if not mail.is_failed %}class="active"{% endif %}>
|
||||||
|
<a href="#tab-html" role="tab" data-toggle="tab">
|
||||||
|
<span class="fa fa-eye"></span>
|
||||||
|
{% trans "HTML content" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li role="presentation"
|
||||||
|
{% if not mail.is_failed and not mail.body_html %}class="active"{% endif %}>
|
||||||
|
<a href="#tab-text" role="tab" data-toggle="tab">
|
||||||
|
<span class="fa fa-file-text-o"></span>
|
||||||
|
{% trans "Text content" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li role="presentation">
|
||||||
|
<a href="#tab-headers" role="tab" data-toggle="tab">
|
||||||
|
<span class="fa fa-code"></span>
|
||||||
|
{% trans "Headers" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
{% if mail.is_failed %}
|
||||||
|
<div role="tabpanel" class="tab-pane active" id="tab-error">
|
||||||
|
<strong>
|
||||||
|
{{ mail.error }}
|
||||||
|
</strong>
|
||||||
|
<pre>{{ mail.error_detail }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if mail.body_html %}
|
||||||
|
<div role="tabpanel"
|
||||||
|
class="tab-pane {% if not mail.is_failed %}active{% endif %}"
|
||||||
|
id="tab-html">
|
||||||
|
{% if mail.sensitive %}
|
||||||
|
<div class="empty-collection">
|
||||||
|
<p>
|
||||||
|
{% icon "eye-slash fa-4x" %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Sensitive content not shown for security reasons
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ data_url|json_script:"mail_body_html" }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div role="tabpanel"
|
||||||
|
class="tab-pane {% if not mail.is_failed and not mail.body_html %}active{% endif %}"
|
||||||
|
id="tab-text">
|
||||||
|
{% if mail.sensitive %}
|
||||||
|
<div class="empty-collection">
|
||||||
|
<p>
|
||||||
|
{% icon "eye-slash fa-4x" %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
Sensitive content not shown for security reasons
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<pre><code>{{ mail.body_plain }}</code></pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel"
|
||||||
|
class="tab-pane"
|
||||||
|
id="tab-headers">
|
||||||
|
<pre><code>{% for k, v in mail.headers.items %}{{ k }}: {{ v }}<br>{% endfor %}</code></pre>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% trans "Additional headers will be added by the mail server and are not visible here." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% compress js %}
|
||||||
|
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/outgoingmail.js" %}"></script>
|
||||||
|
{% endcompress %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
{% extends "pretixcontrol/organizers/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load bootstrap3 %}
|
||||||
|
{% load urlreplace %}
|
||||||
|
{% load icon %}
|
||||||
|
{% block inner %}
|
||||||
|
<h1>
|
||||||
|
{% trans "Outgoing emails" %}
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed with days=days %}
|
||||||
|
This is an overview of all emails sent by your organizer account in the last {{ days }} days.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% if mails|length == 0 and not filter_form.filtered %}
|
||||||
|
<div class="empty-collection">
|
||||||
|
<p>
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
You haven't sent any emails recently.
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">{% trans "Filter" %}</h3>
|
||||||
|
</div>
|
||||||
|
<form class="panel-body filter-form" action="" method="get">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.query %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.status %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5 col-sm-6 col-xs-12">
|
||||||
|
{% bootstrap_field filter_form.event %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">
|
||||||
|
<span class="fa fa-filter"></span>
|
||||||
|
{% trans "Filter" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form action="{% url "control:organizer.outgoingmails.bulk_action" organizer=request.organizer.slug %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for field in filter_form %}
|
||||||
|
{{ field.as_hidden }}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-condensed table-hover table-quotas">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||||
|
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||||
|
</th>
|
||||||
|
<th>{% trans "Subject" %}</th>
|
||||||
|
<th>{% trans "Recipients" %}</th>
|
||||||
|
<th>{% trans "Context" %}</th>
|
||||||
|
<th>{% trans "Status" %}</th>
|
||||||
|
<th>{% trans "Date" %}
|
||||||
|
<a href="?{% url_replace request 'ordering' '-date' %}"><i
|
||||||
|
class="fa fa-caret-down"></i></a>
|
||||||
|
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
|
||||||
|
</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
{% if page_obj.paginator.num_pages > 1 %}
|
||||||
|
<tr class="table-select-all warning hidden">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="__ALL" id="__all"
|
||||||
|
data-results-total="{{ page_obj.paginator.count }}">
|
||||||
|
</td>
|
||||||
|
<td colspan="7">
|
||||||
|
<label for="__all">
|
||||||
|
{% trans "Select all results on other pages as well" %}
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in mails %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<label aria-label="{% trans "select row for batch-operation" %}"
|
||||||
|
class="batch-select-label"><input type="checkbox" name="outgoingmail"
|
||||||
|
class="batch-select-checkbox"
|
||||||
|
value="{{ m.pk }}"/></label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}">
|
||||||
|
{{ m.subject }}
|
||||||
|
</a>
|
||||||
|
{% if m.sensitive %}
|
||||||
|
<span class="text-muted">{% icon "eye-slash" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.to|join:", " }}
|
||||||
|
{% if m.cc %}
|
||||||
|
<br><small class="text-muted">{% trans "Cc" context "email" %}: {{ m.cc|join:", " }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if m.bcc %}
|
||||||
|
<br><small class="text-muted">{% trans "Bcc" context "email" %}: {{ m.bcc|join:", " }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if m.event %}
|
||||||
|
<div>
|
||||||
|
{% icon "calendar fa-fw" %}
|
||||||
|
<a href="{% url "control:event.index" organizer=request.organizer.slug event=m.event.slug %}">
|
||||||
|
{{ m.event }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if m.order %}
|
||||||
|
<div>
|
||||||
|
{% icon "shopping-cart fa-fw" %}
|
||||||
|
<a href="{% url "control:event.order" organizer=request.organizer.slug event=m.event.slug code=m.order.code %}">
|
||||||
|
{{ m.order.code }}</a>{% if m.orderposition %}-{{ m.orderposition.positionid }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if m.customer %}
|
||||||
|
<div>
|
||||||
|
{% icon "user fa-fw" %}
|
||||||
|
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
|
||||||
|
{{ m.customer }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if m.status == "queued" %}
|
||||||
|
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
|
||||||
|
{% elif m.status == "inflight" %}
|
||||||
|
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
|
||||||
|
{% elif m.status == "awaiting_retry" %}
|
||||||
|
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
|
||||||
|
{% elif m.status == "failed" %}
|
||||||
|
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
|
||||||
|
{% elif m.status == "bounced" %}
|
||||||
|
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
|
||||||
|
{% elif m.status == "withheld" %}
|
||||||
|
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
|
||||||
|
{% elif m.status == "aborted" %}
|
||||||
|
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
|
||||||
|
{% elif m.status == "sent" %}
|
||||||
|
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ m.created|date:"SHORT_DATETIME_FORMAT" }}
|
||||||
|
{% if m.sent %}
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">{% trans "Sent:" %} {{ m.sent|date:"SHORT_DATETIME_FORMAT" }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right flip">
|
||||||
|
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}"
|
||||||
|
class="btn btn-default btn-sm">{% icon "eye" %}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="batch-select-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-save" name="action" value="retry">
|
||||||
|
{% icon "repeat" %}
|
||||||
|
{% trans "Retry (if failed or withheld)" %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-danger btn-save" name="action" value="abort">
|
||||||
|
{% icon "ban" %}
|
||||||
|
{% trans "Abort (if queued, awaiting retry or withheld)" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% include "pretixcontrol/pagination.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -38,8 +38,9 @@ from django.views.generic.base import RedirectView
|
|||||||
|
|
||||||
from pretix.control.views import (
|
from pretix.control.views import (
|
||||||
auth, checkin, dashboards, datasync, discounts, event, geo,
|
auth, checkin, dashboards, datasync, discounts, event, geo,
|
||||||
global_settings, item, main, modelimport, oauth, orders, organizer, pdf,
|
global_settings, item, mail, main, modelimport, oauth, orders, organizer,
|
||||||
search, shredder, subevents, typeahead, user, users, vouchers, waitinglist,
|
pdf, search, shredder, subevents, typeahead, user, users, vouchers,
|
||||||
|
waitinglist,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -240,6 +241,9 @@ urlpatterns = [
|
|||||||
name='organizer.gate.edit'),
|
name='organizer.gate.edit'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(),
|
||||||
name='organizer.gate.delete'),
|
name='organizer.gate.delete'),
|
||||||
|
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmails$', mail.OutgoingMailListView.as_view(), name='organizer.outgoingmails'),
|
||||||
|
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/bulk_action$', mail.OutgoingMailBulkAction.as_view(), name='organizer.outgoingmails.bulk_action'),
|
||||||
|
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/(?P<mail>[0-9]+)/$', mail.OutgoingMailDetailView.as_view(), name='organizer.outgoingmail'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
|
||||||
re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
|
re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from django_otp import match_token
|
from django_otp import match_token
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
from webauthn.helpers import generate_challenge
|
from webauthn.helpers import generate_challenge
|
||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
@@ -65,7 +66,6 @@ from pretix.base.forms.auth import (
|
|||||||
)
|
)
|
||||||
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
|
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
|
||||||
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
from pretix.helpers.http import get_client_ip, redirect_to_url
|
from pretix.helpers.http import get_client_ip, redirect_to_url
|
||||||
from pretix.helpers.security import handle_login_source
|
from pretix.helpers.security import handle_login_source
|
||||||
|
|
||||||
@@ -346,9 +346,6 @@ class Forgot(TemplateView):
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
|
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
|
||||||
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Sending password reset email to \"' + email + '\" failed.')
|
|
||||||
|
|
||||||
except RepeatedResetDenied:
|
except RepeatedResetDenied:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -363,7 +360,7 @@ class Forgot(TemplateView):
|
|||||||
else:
|
else:
|
||||||
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
|
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
|
||||||
|
|
||||||
return redirect('control:auth.forgot')
|
return redirect('control:auth.forgot')
|
||||||
else:
|
else:
|
||||||
return self.get(request, *args, **kwargs)
|
return self.get(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -538,6 +535,10 @@ class Login2FAView(TemplateView):
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
valid = match_token(self.user, token)
|
valid = match_token(self.user, token)
|
||||||
|
if isinstance(valid, StaticDevice):
|
||||||
|
self.user.send_security_notice([
|
||||||
|
_("A recovery code for two-factor authentification was used to log in.")
|
||||||
|
])
|
||||||
|
|
||||||
if valid:
|
if valid:
|
||||||
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
|
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
|
|||||||
return None, None
|
return None, None
|
||||||
try:
|
try:
|
||||||
for k, v in pkg.metadata.items():
|
for k, v in pkg.metadata.items():
|
||||||
|
if k == "License-Expression":
|
||||||
|
license = v
|
||||||
if k == "License":
|
if k == "License":
|
||||||
license = v
|
license = v
|
||||||
if k == "Home-page":
|
if k == "Home-page":
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from email.header import decode_header, make_header
|
||||||
|
from email.utils import parseaddr
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.core.exceptions import BadRequest
|
||||||
|
from django.db import transaction
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.translation import ngettext
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
|
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
|
||||||
|
from pretix.base.models import OutgoingMail
|
||||||
|
from pretix.base.services.mail import mail_send_task
|
||||||
|
from pretix.control.forms.filter import OutgoingMailFilterForm
|
||||||
|
from pretix.control.permissions import OrganizerPermissionRequiredMixin
|
||||||
|
from pretix.control.views.organizer import OrganizerDetailViewMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMailQueryMixin:
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def request_data(self):
|
||||||
|
if self.request.method == "POST":
|
||||||
|
d = self.request.POST
|
||||||
|
else:
|
||||||
|
d = self.request.GET
|
||||||
|
d = d.copy()
|
||||||
|
return d
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def filter_form(self):
|
||||||
|
return OutgoingMailFilterForm(
|
||||||
|
data=self.request_data,
|
||||||
|
request=self.request,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = self.request.organizer.outgoing_mails.select_related(
|
||||||
|
'event', 'order', 'orderposition', 'customer'
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'outgoingmail' in self.request_data and '__ALL' not in self.request_data:
|
||||||
|
qs = qs.filter(
|
||||||
|
id__in=self.request_data.getlist('outgoingmail')
|
||||||
|
)
|
||||||
|
elif self.request.method == 'GET' or '__ALL' in self.request_data:
|
||||||
|
if self.filter_form.is_valid():
|
||||||
|
qs = self.filter_form.filter_qs(qs)
|
||||||
|
else:
|
||||||
|
raise BadRequest("No mails selected")
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMailListView(OutgoingMailQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||||
|
model = OutgoingMail
|
||||||
|
template_name = 'pretixcontrol/organizers/outgoing_mails.html'
|
||||||
|
# Assume "the highest" permission level for now because emails could belog to any event, order, or customer.
|
||||||
|
# We plan to add a special permissoin in the future
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
context_object_name = 'mails'
|
||||||
|
paginate_by = 100
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
ctx['filter_form'] = self.filter_form
|
||||||
|
ctx['days'] = int(settings.OUTGOING_MAIL_RETENTION / (24 * 3600))
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
|
||||||
|
model = OutgoingMail
|
||||||
|
template_name = 'pretixcontrol/organizers/outgoing_mail.html'
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
context_object_name = 'mail'
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
return get_object_or_404(OutgoingMail, organizer=self.request.organizer, pk=self.kwargs.get('mail'))
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
|
if 'Content-Security-Policy' in response:
|
||||||
|
h = _parse_csp(response['Content-Security-Policy'])
|
||||||
|
else:
|
||||||
|
h = {}
|
||||||
|
csps = {
|
||||||
|
'frame-src': ['data:'],
|
||||||
|
# Unfortuantely, we can't avoid unsafe-inline for style here.
|
||||||
|
# See outgoingmail.js for the protection measures we take.
|
||||||
|
'style-src': ["'unsafe-inline'"],
|
||||||
|
}
|
||||||
|
_merge_csp(h, csps)
|
||||||
|
response['Content-Security-Policy'] = _render_csp(h)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
if self.object.body_html:
|
||||||
|
ctx['data_url'] = "data:text/html;charset=utf-8;base64," + base64.b64encode(self.object.body_html.encode()).decode()
|
||||||
|
|
||||||
|
from_name, from_email = parseaddr(self.object.sender)
|
||||||
|
if from_name:
|
||||||
|
from_name = make_header(decode_header(from_name))
|
||||||
|
ctx['sender'] = "{} <{}>".format(from_name, from_email) if from_name else from_email
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMailBulkAction(OutgoingMailQueryMixin, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, View):
|
||||||
|
permission = 'can_change_organizer_settings'
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if request.POST.get('action') == 'retry':
|
||||||
|
ids = set(
|
||||||
|
self.get_queryset().filter(status__in=OutgoingMail.STATUS_LIST_RETRYABLE).values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
with transaction.atomic():
|
||||||
|
OutgoingMail.objects.filter(pk__in=ids).update(
|
||||||
|
status=OutgoingMail.STATUS_QUEUED,
|
||||||
|
sent=None,
|
||||||
|
)
|
||||||
|
self.request.organizer.log_action(
|
||||||
|
'pretix.organizer.outgoingmails.retried', user=self.request.user, data={
|
||||||
|
'mails': list(ids)
|
||||||
|
}, save=False
|
||||||
|
)
|
||||||
|
for i in ids:
|
||||||
|
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
|
||||||
|
|
||||||
|
messages.success(request, ngettext(
|
||||||
|
"A retry of one email was scheduled.",
|
||||||
|
"A retry of {num} emails was scheduled.",
|
||||||
|
len(ids),
|
||||||
|
).format(num=len(ids)))
|
||||||
|
elif request.POST.get('action') == 'abort':
|
||||||
|
ids = set(
|
||||||
|
self.get_queryset().filter(
|
||||||
|
status__in=(OutgoingMail.STATUS_QUEUED, OutgoingMail.STATUS_AWAITING_RETRY)
|
||||||
|
).values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
with transaction.atomic():
|
||||||
|
OutgoingMail.objects.filter(pk__in=ids).update(
|
||||||
|
status=OutgoingMail.STATUS_ABORTED,
|
||||||
|
sent=None,
|
||||||
|
)
|
||||||
|
self.request.organizer.log_action(
|
||||||
|
'pretix.organizer.outgoingmails.aborted', user=self.request.user, data={
|
||||||
|
'mails': list(ids)
|
||||||
|
}, save=False
|
||||||
|
)
|
||||||
|
for i in ids:
|
||||||
|
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
|
||||||
|
|
||||||
|
messages.success(request, ngettext(
|
||||||
|
"One email was aborted and will not be sent.",
|
||||||
|
"{num} emails were aborted and will not be sent.",
|
||||||
|
len(ids),
|
||||||
|
).format(num=len(ids)))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self) -> str:
|
||||||
|
return reverse('control:organizer.outgoingmails', kwargs={
|
||||||
|
'organizer': self.request.organizer.slug,
|
||||||
|
})
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -97,9 +98,7 @@ from pretix.base.services.invoices import (
|
|||||||
invoice_qualified, regenerate_invoice, transmit_invoice,
|
invoice_qualified, regenerate_invoice, transmit_invoice,
|
||||||
)
|
)
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.mail import (
|
from pretix.base.services.mail import prefix_subject, render_mail
|
||||||
SendMailException, prefix_subject, render_mail,
|
|
||||||
)
|
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
|
||||||
extend_order, mark_order_expired, mark_order_refunded,
|
extend_order, mark_order_expired, mark_order_refunded,
|
||||||
@@ -508,9 +507,10 @@ class OrderView(EventPermissionRequiredMixin, DetailView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['can_generate_invoice'] = invoice_qualified(self.order) and (
|
ctx['invoice_qualified'] = invoice_qualified(self.order)
|
||||||
|
ctx['can_generate_invoice'] = ctx['invoice_qualified'] and (
|
||||||
self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True')
|
self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True')
|
||||||
) and self.order.status in (Order.STATUS_PAID, Order.STATUS_PENDING) and (
|
) and (
|
||||||
not self.order.invoices.exists()
|
not self.order.invoices.exists()
|
||||||
or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
|
or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
|
||||||
)
|
)
|
||||||
@@ -1064,10 +1064,6 @@ class OrderPaymentConfirm(OrderView):
|
|||||||
messages.error(self.request, str(e))
|
messages.error(self.request, str(e))
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
messages.error(self.request, str(e))
|
messages.error(self.request, str(e))
|
||||||
except SendMailException:
|
|
||||||
messages.warning(self.request,
|
|
||||||
_('The payment has been marked as complete, but we were unable to send a '
|
|
||||||
'confirmation mail.'))
|
|
||||||
else:
|
else:
|
||||||
messages.success(self.request, _('The payment has been marked as complete.'))
|
messages.success(self.request, _('The payment has been marked as complete.'))
|
||||||
else:
|
else:
|
||||||
@@ -1230,7 +1226,11 @@ class OrderRefundView(OrderView):
|
|||||||
customer=order.customer,
|
customer=order.customer,
|
||||||
testmode=order.testmode
|
testmode=order.testmode
|
||||||
)
|
)
|
||||||
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={})
|
giftcard.log_action(
|
||||||
|
action='pretix.giftcards.created',
|
||||||
|
user=self.request.user,
|
||||||
|
data={}
|
||||||
|
)
|
||||||
refunds.append(OrderRefund(
|
refunds.append(OrderRefund(
|
||||||
order=order,
|
order=order,
|
||||||
payment=None,
|
payment=None,
|
||||||
@@ -1538,9 +1538,6 @@ class OrderTransition(OrderView):
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
})
|
})
|
||||||
messages.error(self.request, str(e))
|
messages.error(self.request, str(e))
|
||||||
except SendMailException:
|
|
||||||
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
|
|
||||||
'confirmation mail.'))
|
|
||||||
else:
|
else:
|
||||||
messages.success(self.request, _('The payment has been created successfully.'))
|
messages.success(self.request, _('The payment has been created successfully.'))
|
||||||
elif self.order.cancel_allowed() and to == 'c':
|
elif self.order.cancel_allowed() and to == 'c':
|
||||||
@@ -1741,14 +1738,15 @@ class OrderInvoiceReissue(OrderView):
|
|||||||
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
|
messages.error(self.request, _('The invoice has been cleaned of personal data.'))
|
||||||
else:
|
else:
|
||||||
c = generate_cancellation(inv)
|
c = generate_cancellation(inv)
|
||||||
if order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
|
if invoice_qualified(order):
|
||||||
inv = generate_invoice(order)
|
inv = generate_invoice(order)
|
||||||
|
messages.success(self.request, _('The invoice has been reissued.'))
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
|
messages.success(self.request, _('The invoice has been canceled.'))
|
||||||
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
|
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
|
||||||
'invoice': inv.pk
|
'invoice': inv.pk
|
||||||
})
|
})
|
||||||
messages.success(self.request, _('The invoice has been reissued.'))
|
|
||||||
return redirect(self.get_order_url())
|
return redirect(self.get_order_url())
|
||||||
|
|
||||||
def get(self, *args, **kwargs): # NOQA
|
def get(self, *args, **kwargs): # NOQA
|
||||||
@@ -1778,15 +1776,11 @@ class OrderResendLink(OrderView):
|
|||||||
permission = 'can_change_orders'
|
permission = 'can_change_orders'
|
||||||
|
|
||||||
def post(self, *args, **kwargs):
|
def post(self, *args, **kwargs):
|
||||||
try:
|
if 'position' in kwargs:
|
||||||
if 'position' in kwargs:
|
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
|
||||||
p = get_object_or_404(self.order.positions, pk=kwargs['position'])
|
p.resend_link(user=self.request.user)
|
||||||
p.resend_link(user=self.request.user)
|
else:
|
||||||
else:
|
self.order.resend_link(user=self.request.user)
|
||||||
self.order.resend_link(user=self.request.user)
|
|
||||||
except SendMailException:
|
|
||||||
messages.error(self.request, _('There was an error sending the mail. Please try again later.'))
|
|
||||||
return redirect(self.get_order_url())
|
|
||||||
|
|
||||||
messages.success(self.request, _('The email has been queued to be sent.'))
|
messages.success(self.request, _('The email has been queued to be sent.'))
|
||||||
return redirect(self.get_order_url())
|
return redirect(self.get_order_url())
|
||||||
@@ -2430,24 +2424,18 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
|
|||||||
}
|
}
|
||||||
return self.get(self.request, *self.args, **self.kwargs)
|
return self.get(self.request, *self.args, **self.kwargs)
|
||||||
else:
|
else:
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
form.cleaned_data['subject'], email_template,
|
||||||
form.cleaned_data['subject'], email_template,
|
email_context, 'pretix.event.order.email.custom_sent',
|
||||||
email_context, 'pretix.event.order.email.custom_sent',
|
self.request.user, auto_email=False,
|
||||||
self.request.user, auto_email=False,
|
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
invoices=form.cleaned_data.get('attach_invoices', []),
|
||||||
invoices=form.cleaned_data.get('attach_invoices', []),
|
attach_other_files=[a for a in [
|
||||||
attach_other_files=[a for a in [
|
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
)
|
||||||
)
|
messages.success(self.request,
|
||||||
messages.success(self.request,
|
_('Your message has been queued and will be sent to {}.'.format(order.email)))
|
||||||
_('Your message has been queued and will be sent to {}.'.format(order.email)))
|
|
||||||
except SendMailException:
|
|
||||||
messages.error(
|
|
||||||
self.request,
|
|
||||||
_('Failed to send mail to the following user: {}'.format(order.email))
|
|
||||||
)
|
|
||||||
return super(OrderSendMail, self).form_valid(form)
|
return super(OrderSendMail, self).form_valid(form)
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
@@ -2500,23 +2488,19 @@ class OrderPositionSendMail(OrderSendMail):
|
|||||||
}
|
}
|
||||||
return self.get(self.request, *self.args, **self.kwargs)
|
return self.get(self.request, *self.args, **self.kwargs)
|
||||||
else:
|
else:
|
||||||
try:
|
position.send_mail(
|
||||||
position.send_mail(
|
form.cleaned_data['subject'],
|
||||||
form.cleaned_data['subject'],
|
email_template,
|
||||||
email_template,
|
email_context,
|
||||||
email_context,
|
'pretix.event.order.position.email.custom_sent',
|
||||||
'pretix.event.order.position.email.custom_sent',
|
self.request.user,
|
||||||
self.request.user,
|
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
||||||
attach_tickets=form.cleaned_data.get('attach_tickets', False),
|
attach_other_files=[a for a in [
|
||||||
attach_other_files=[a for a in [
|
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||||
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
||||||
] if a] if form.cleaned_data.get('attach_new_order', False) else [],
|
)
|
||||||
)
|
messages.success(self.request,
|
||||||
messages.success(self.request,
|
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
|
||||||
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
|
|
||||||
except SendMailException:
|
|
||||||
messages.error(self.request,
|
|
||||||
_('Failed to send mail to the following user: {}'.format(position.attendee_email)))
|
|
||||||
return super(OrderSendMail, self).form_valid(form)
|
return super(OrderSendMail, self).form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
@@ -2677,8 +2661,8 @@ class ExportMixin:
|
|||||||
if id != ex.identifier:
|
if id != ex.identifier:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.scheduled:
|
if self.scheduled or self.scheduled_copy_from:
|
||||||
initial = dict(self.scheduled.export_form_data)
|
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
|
||||||
|
|
||||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||||
test_form.fields = ex.export_form_fields
|
test_form.fields = ex.export_form_fields
|
||||||
@@ -2721,6 +2705,11 @@ class ExportMixin:
|
|||||||
elif "scheduled" in self.request.GET:
|
elif "scheduled" in self.request.GET:
|
||||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def scheduled_copy_from(self):
|
||||||
|
if "scheduled_copy_from" in self.request.GET:
|
||||||
|
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['exporters'] = self.exporters
|
ctx['exporters'] = self.exporters
|
||||||
@@ -2790,7 +2779,16 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if request.POST.get("schedule") == "save":
|
if request.POST.get("schedule") == "save":
|
||||||
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
if not self.has_permission():
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"Your user account does not have sufficient permission to run this report, therefore "
|
||||||
|
"you cannot schedule it."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
||||||
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
||||||
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
||||||
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
||||||
@@ -2829,6 +2827,8 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
def rrule_form(self):
|
def rrule_form(self):
|
||||||
if self.scheduled:
|
if self.scheduled:
|
||||||
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
||||||
|
elif self.scheduled_copy_from:
|
||||||
|
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
|
||||||
else:
|
else:
|
||||||
initial = {}
|
initial = {}
|
||||||
return RRuleForm(
|
return RRuleForm(
|
||||||
@@ -2839,11 +2839,15 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def schedule_form(self):
|
def schedule_form(self):
|
||||||
instance = self.scheduled or ScheduledEventExport(
|
if self.scheduled_copy_from:
|
||||||
event=self.request.event,
|
instance = copy.copy(self.scheduled_copy_from)
|
||||||
owner=self.request.user,
|
instance.pk = None
|
||||||
)
|
else:
|
||||||
if not self.scheduled:
|
instance = self.scheduled or ScheduledEventExport(
|
||||||
|
event=self.request.event,
|
||||||
|
owner=self.request.user,
|
||||||
|
)
|
||||||
|
if not self.scheduled and not self.scheduled_copy_from:
|
||||||
initial = {
|
initial = {
|
||||||
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
||||||
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
|
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
|
||||||
@@ -2868,18 +2872,11 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
if "schedule" in self.request.POST or self.scheduled:
|
|
||||||
if "schedule" in self.request.POST and not self.has_permission():
|
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
|
||||||
messages.error(
|
ctx['schedule_form'] = self.schedule_form
|
||||||
self.request,
|
ctx['rrule_form'] = self.rrule_form
|
||||||
_(
|
ctx['scheduled_copy_from'] = self.scheduled_copy_from
|
||||||
"Your user account does not have sufficient permission to run this report, therefore "
|
|
||||||
"you cannot schedule it."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ctx['schedule_form'] = self.schedule_form
|
|
||||||
ctx['rrule_form'] = self.rrule_form
|
|
||||||
elif not self.exporter:
|
elif not self.exporter:
|
||||||
for s in ctx['scheduled']:
|
for s in ctx['scheduled']:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -102,7 +103,7 @@ from pretix.base.plugins import (
|
|||||||
PLUGIN_LEVEL_ORGANIZER,
|
PLUGIN_LEVEL_ORGANIZER,
|
||||||
)
|
)
|
||||||
from pretix.base.services.export import multiexport, scheduled_organizer_export
|
from pretix.base.services.export import multiexport, scheduled_organizer_export
|
||||||
from pretix.base.services.mail import SendMailException, mail, prefix_subject
|
from pretix.base.services.mail import mail, prefix_subject
|
||||||
from pretix.base.signals import register_multievent_data_exporters
|
from pretix.base.signals import register_multievent_data_exporters
|
||||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
@@ -1036,24 +1037,21 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def _send_invite(self, instance):
|
def _send_invite(self, instance):
|
||||||
try:
|
mail(
|
||||||
mail(
|
instance.email,
|
||||||
instance.email,
|
_('pretix account invitation'),
|
||||||
_('pretix account invitation'),
|
'pretixcontrol/email/invitation.txt',
|
||||||
'pretixcontrol/email/invitation.txt',
|
{
|
||||||
{
|
'user': self,
|
||||||
'user': self,
|
'organizer': self.request.organizer.name,
|
||||||
'organizer': self.request.organizer.name,
|
'team': instance.team.name,
|
||||||
'team': instance.team.name,
|
'url': build_global_uri('control:auth.invite', kwargs={
|
||||||
'url': build_global_uri('control:auth.invite', kwargs={
|
'token': instance.token
|
||||||
'token': instance.token
|
})
|
||||||
})
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
locale=self.request.LANGUAGE_CODE
|
||||||
locale=self.request.LANGUAGE_CODE
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
@@ -1669,9 +1667,12 @@ class GiftCardAcceptanceInviteView(OrganizerDetailViewMixin, OrganizerPermission
|
|||||||
active=False,
|
active=False,
|
||||||
)
|
)
|
||||||
self.request.organizer.log_action(
|
self.request.organizer.log_action(
|
||||||
'pretix.giftcards.acceptance.acceptor.invited',
|
action='pretix.giftcards.acceptance.acceptor.invited',
|
||||||
data={'acceptor': form.cleaned_data['acceptor'].slug,
|
data={
|
||||||
'reusable_media': form.cleaned_data['reusable_media']},
|
'acceptor': form.cleaned_data['acceptor'].slug,
|
||||||
|
'issuer': self.request.organizer.slug,
|
||||||
|
'reusable_media': form.cleaned_data['reusable_media']
|
||||||
|
},
|
||||||
user=self.request.user
|
user=self.request.user
|
||||||
)
|
)
|
||||||
messages.success(self.request, _('The selected organizer has been invited.'))
|
messages.success(self.request, _('The selected organizer has been invited.'))
|
||||||
@@ -1707,8 +1708,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
|
|||||||
).delete()
|
).delete()
|
||||||
if done:
|
if done:
|
||||||
self.request.organizer.log_action(
|
self.request.organizer.log_action(
|
||||||
'pretix.giftcards.acceptance.acceptor.removed',
|
action='pretix.giftcards.acceptance.acceptor.removed',
|
||||||
data={'acceptor': request.POST.get("delete_acceptor")},
|
data={
|
||||||
|
'acceptor': request.POST.get("delete_acceptor"),
|
||||||
|
'issuer': self.request.organizer.slug
|
||||||
|
},
|
||||||
user=request.user
|
user=request.user
|
||||||
)
|
)
|
||||||
messages.success(self.request, _('The selected connection has been removed.'))
|
messages.success(self.request, _('The selected connection has been removed.'))
|
||||||
@@ -1718,8 +1722,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
|
|||||||
).delete()
|
).delete()
|
||||||
if done:
|
if done:
|
||||||
self.request.organizer.log_action(
|
self.request.organizer.log_action(
|
||||||
'pretix.giftcards.acceptance.issuer.removed',
|
action='pretix.giftcards.acceptance.issuer.removed',
|
||||||
data={'issuer': request.POST.get("delete_acceptor")},
|
data={
|
||||||
|
'issuer': request.POST.get("delete_acceptor"),
|
||||||
|
'acceptor': self.request.organizer.slug
|
||||||
|
},
|
||||||
user=request.user
|
user=request.user
|
||||||
)
|
)
|
||||||
messages.success(self.request, _('The selected connection has been removed.'))
|
messages.success(self.request, _('The selected connection has been removed.'))
|
||||||
@@ -1729,8 +1736,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
|
|||||||
).update(active=True)
|
).update(active=True)
|
||||||
if done:
|
if done:
|
||||||
self.request.organizer.log_action(
|
self.request.organizer.log_action(
|
||||||
'pretix.giftcards.acceptance.issuer.accepted',
|
action='pretix.giftcards.acceptance.issuer.accepted',
|
||||||
data={'issuer': request.POST.get("accept_issuer")},
|
data={
|
||||||
|
'issuer': request.POST.get("accept_issuer"),
|
||||||
|
'acceptor': self.request.organizer.slug
|
||||||
|
},
|
||||||
user=request.user
|
user=request.user
|
||||||
)
|
)
|
||||||
messages.success(self.request, _('The selected connection has been accepted.'))
|
messages.success(self.request, _('The selected connection has been accepted.'))
|
||||||
@@ -1836,10 +1846,11 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
acceptor=request.organizer,
|
acceptor=request.organizer,
|
||||||
)
|
)
|
||||||
self.object.log_action(
|
self.object.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
data={
|
data={
|
||||||
'value': value,
|
'value': value,
|
||||||
'text': request.POST.get('text')
|
'text': request.POST.get('text'),
|
||||||
|
'acceptor_id': self.request.organizer.id
|
||||||
},
|
},
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
)
|
)
|
||||||
@@ -1888,15 +1899,23 @@ class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
messages.success(self.request, _('The gift card has been created and can now be used.'))
|
messages.success(self.request, _('The gift card has been created and can now be used.'))
|
||||||
form.instance.issuer = self.request.organizer
|
form.instance.issuer = self.request.organizer
|
||||||
super().form_valid(form)
|
super().form_valid(form)
|
||||||
form.instance.transactions.create(
|
form.instance.log_action(
|
||||||
acceptor=self.request.organizer,
|
action='pretix.giftcards.created',
|
||||||
value=form.cleaned_data['value']
|
user=self.request.user,
|
||||||
)
|
)
|
||||||
form.instance.log_action('pretix.giftcards.created', user=self.request.user, data={})
|
|
||||||
if form.cleaned_data['value']:
|
if form.cleaned_data['value']:
|
||||||
form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={
|
form.instance.transactions.create(
|
||||||
'value': form.cleaned_data['value']
|
acceptor=self.request.organizer,
|
||||||
})
|
value=form.cleaned_data['value']
|
||||||
|
)
|
||||||
|
form.instance.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.request.user,
|
||||||
|
data={
|
||||||
|
'value': form.cleaned_data['value'],
|
||||||
|
'acceptor_id': self.request.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
return redirect(reverse(
|
return redirect(reverse(
|
||||||
'control:organizer.giftcard',
|
'control:organizer.giftcard',
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -1924,7 +1943,11 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
messages.success(self.request, _('The gift card has been changed.'))
|
messages.success(self.request, _('The gift card has been changed.'))
|
||||||
super().form_valid(form)
|
super().form_valid(form)
|
||||||
form.instance.log_action('pretix.giftcards.modified', user=self.request.user, data=dict(form.cleaned_data))
|
form.instance.log_action(
|
||||||
|
action='pretix.giftcards.modified',
|
||||||
|
user=self.request.user,
|
||||||
|
data=dict(form.cleaned_data)
|
||||||
|
)
|
||||||
return redirect(reverse(
|
return redirect(reverse(
|
||||||
'control:organizer.giftcard',
|
'control:organizer.giftcard',
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -1943,8 +1966,8 @@ class ExportMixin:
|
|||||||
for ex in self.exporters:
|
for ex in self.exporters:
|
||||||
if id != ex.identifier:
|
if id != ex.identifier:
|
||||||
continue
|
continue
|
||||||
if self.scheduled:
|
if self.scheduled or self.scheduled_copy_from:
|
||||||
initial = dict(self.scheduled.export_form_data)
|
initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
|
||||||
|
|
||||||
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
|
||||||
test_form.fields = ex.export_form_fields
|
test_form.fields = ex.export_form_fields
|
||||||
@@ -2047,6 +2070,11 @@ class ExportMixin:
|
|||||||
elif "scheduled" in self.request.GET:
|
elif "scheduled" in self.request.GET:
|
||||||
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def scheduled_copy_from(self):
|
||||||
|
if "scheduled_copy_from" in self.request.GET:
|
||||||
|
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
|
||||||
|
|
||||||
|
|
||||||
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
|
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
|
||||||
known_errortypes = ['ExportError', 'ExportEmptyError']
|
known_errortypes = ['ExportError', 'ExportEmptyError']
|
||||||
@@ -2113,7 +2141,16 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if request.POST.get("schedule") == "save":
|
if request.POST.get("schedule") == "save":
|
||||||
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
if not self.has_permission():
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
_(
|
||||||
|
"Your user account does not have sufficient permission to run this report, therefore "
|
||||||
|
"you cannot schedule it."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return super().get(request, *args, **kwargs)
|
||||||
|
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
|
||||||
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
self.schedule_form.instance.export_identifier = self.exporter.identifier
|
||||||
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
|
||||||
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
|
||||||
@@ -2151,6 +2188,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
def rrule_form(self):
|
def rrule_form(self):
|
||||||
if self.scheduled:
|
if self.scheduled:
|
||||||
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
|
||||||
|
elif self.scheduled_copy_from:
|
||||||
|
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
|
||||||
else:
|
else:
|
||||||
initial = {}
|
initial = {}
|
||||||
return RRuleForm(
|
return RRuleForm(
|
||||||
@@ -2162,11 +2201,15 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def schedule_form(self):
|
def schedule_form(self):
|
||||||
instance = self.scheduled or ScheduledOrganizerExport(
|
if self.scheduled_copy_from:
|
||||||
organizer=self.request.organizer,
|
instance = copy.copy(self.scheduled_copy_from)
|
||||||
owner=self.request.user,
|
instance.pk = None
|
||||||
timezone=str(get_current_timezone()),
|
else:
|
||||||
)
|
instance = self.scheduled or ScheduledOrganizerExport(
|
||||||
|
organizer=self.request.organizer,
|
||||||
|
owner=self.request.user,
|
||||||
|
timezone=str(get_current_timezone()),
|
||||||
|
)
|
||||||
if not self.scheduled:
|
if not self.scheduled:
|
||||||
initial = {
|
initial = {
|
||||||
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
|
||||||
@@ -2199,18 +2242,10 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
if "schedule" in self.request.POST or self.scheduled:
|
if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
|
||||||
if "schedule" in self.request.POST and not self.has_permission():
|
ctx['schedule_form'] = self.schedule_form
|
||||||
messages.error(
|
ctx['rrule_form'] = self.rrule_form
|
||||||
self.request,
|
ctx['scheduled_copy_from'] = self.scheduled_copy_from
|
||||||
_(
|
|
||||||
"Your user account does not have sufficient permission to run this report, therefore "
|
|
||||||
"you cannot schedule it."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ctx['schedule_form'] = self.schedule_form
|
|
||||||
ctx['rrule_form'] = self.rrule_form
|
|
||||||
elif not self.exporter:
|
elif not self.exporter:
|
||||||
for s in ctx['scheduled']:
|
for s in ctx['scheduled']:
|
||||||
try:
|
try:
|
||||||
@@ -2583,7 +2618,7 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
|
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
|
||||||
# bear the performance cost
|
# bear the performance cost
|
||||||
qs = self.request.organizer.all_logentries().select_related(
|
qs = self.request.organizer.logentry_set.filter(event=None).select_related(
|
||||||
'user', 'content_type', 'api_token', 'oauth_application', 'device'
|
'user', 'content_type', 'api_token', 'oauth_application', 'device'
|
||||||
).order_by('-datetime')
|
).order_by('-datetime')
|
||||||
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
|
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
|
||||||
@@ -3014,6 +3049,7 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
|
|||||||
locale=self.customer.locale,
|
locale=self.customer.locale,
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
organizer=self.request.organizer,
|
organizer=self.request.organizer,
|
||||||
|
sensitive=True,
|
||||||
)
|
)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ from hijack import signals
|
|||||||
|
|
||||||
from pretix.base.auth import get_auth_backends
|
from pretix.base.auth import get_auth_backends
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
from pretix.control.forms.filter import UserFilterForm
|
from pretix.control.forms.filter import UserFilterForm
|
||||||
from pretix.control.forms.users import UserEditForm
|
from pretix.control.forms.users import UserEditForm
|
||||||
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
from pretix.control.permissions import AdministratorPermissionRequiredMixin
|
||||||
@@ -139,11 +138,7 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||||
try:
|
self.object.send_password_reset()
|
||||||
self.object.send_password_reset()
|
|
||||||
except SendMailException:
|
|
||||||
messages.error(request, _('There was an error sending the mail. Please try again later.'))
|
|
||||||
return redirect(self.get_success_url())
|
|
||||||
|
|
||||||
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
|
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
|
||||||
user=request.user)
|
user=request.user)
|
||||||
@@ -165,6 +160,10 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
|
|||||||
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
|
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
|
||||||
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
|
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
|
||||||
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
|
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
|
||||||
|
self.object.send_security_notice([
|
||||||
|
_('A two-factor emergency code has been generated by a system administrator. This will usually happen '
|
||||||
|
'if you lost access to your two-factor credentials and requested a reset of the credentials.')
|
||||||
|
])
|
||||||
|
|
||||||
messages.success(request, _(
|
messages.success(request, _(
|
||||||
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
|
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
|
||||||
|
|||||||
@@ -136,3 +136,19 @@ custom_translations = [
|
|||||||
gettext_noop("North Macedonia"),
|
gettext_noop("North Macedonia"),
|
||||||
gettext_noop("Macao"),
|
gettext_noop("Macao"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pycountry_add(db, **kw):
|
||||||
|
# Workaround for https://github.com/pycountry/pycountry/issues/281
|
||||||
|
db._load()
|
||||||
|
obj = db.factory(**kw)
|
||||||
|
db.objects.append(obj)
|
||||||
|
for key, value in kw.items():
|
||||||
|
if key in db.no_index:
|
||||||
|
continue
|
||||||
|
value = value.lower()
|
||||||
|
index = db.indices.setdefault(key, {})
|
||||||
|
if key in ["country_code"]:
|
||||||
|
index.setdefault(value, set()).add(obj)
|
||||||
|
else:
|
||||||
|
index[value] = obj
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
|
||||||
from django.db import connection, transaction
|
from django.db import connection, transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Aggregate, Expression, F, Field, Lookup, OrderBy, Value,
|
Aggregate, Expression, F, Field, JSONField, Lookup, OrderBy, Value,
|
||||||
)
|
)
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
|
||||||
@@ -154,6 +154,19 @@ class NotEqual(Lookup):
|
|||||||
return '%s <> %s' % (lhs, rhs), params
|
return '%s <> %s' % (lhs, rhs), params
|
||||||
|
|
||||||
|
|
||||||
|
@JSONField.register_lookup
|
||||||
|
class ContainsString(Lookup):
|
||||||
|
lookup_name = 'containsstring'
|
||||||
|
|
||||||
|
def as_sql(self, compiler, connection):
|
||||||
|
if connection.vendor != "postgresql":
|
||||||
|
raise NotImplementedError("Lookup in JSON Array not supported on this database")
|
||||||
|
lhs, lhs_params = self.process_lhs(compiler, connection)
|
||||||
|
rhs, rhs_params = self.process_rhs(compiler, connection)
|
||||||
|
params = lhs_params + rhs_params
|
||||||
|
return '%s ? %s' % (lhs, rhs), params
|
||||||
|
|
||||||
|
|
||||||
class PostgresWindowFrame(Expression):
|
class PostgresWindowFrame(Expression):
|
||||||
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
|
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from string import Formatter
|
from string import Formatter
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,6 +38,17 @@ class PlainHtmlAlternativeString:
|
|||||||
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
|
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
|
||||||
|
|
||||||
|
|
||||||
|
class FormattedString(str):
|
||||||
|
"""
|
||||||
|
A str subclass that has been specifically marked as "already formatted" for email rendering
|
||||||
|
purposes to avoid duplicate formatting.
|
||||||
|
"""
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SafeFormatter(Formatter):
|
class SafeFormatter(Formatter):
|
||||||
"""
|
"""
|
||||||
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
||||||
@@ -77,8 +89,19 @@ class SafeFormatter(Formatter):
|
|||||||
# Ignore format_spec
|
# Ignore format_spec
|
||||||
return super().format_field(self._prepare_value(value), '')
|
return super().format_field(self._prepare_value(value), '')
|
||||||
|
|
||||||
|
def convert_field(self, value, conversion):
|
||||||
|
# Ignore any conversions
|
||||||
|
if conversion is None:
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
|
|
||||||
|
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None) -> FormattedString:
|
||||||
|
if isinstance(template, FormattedString):
|
||||||
|
raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.")
|
||||||
if not isinstance(template, str):
|
if not isinstance(template, str):
|
||||||
template = str(template)
|
template = str(template)
|
||||||
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
return FormattedString(
|
||||||
|
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from django_countries.fields import Country
|
|||||||
from geoip2.errors import AddressNotFoundError
|
from geoip2.errors import AddressNotFoundError
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.helpers.http import get_client_ip
|
from pretix.helpers.http import get_client_ip
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
@@ -159,21 +159,18 @@ def handle_login_source(user, request):
|
|||||||
})
|
})
|
||||||
if user.known_login_sources.count() > 1:
|
if user.known_login_sources.count() > 1:
|
||||||
# Do not send on first login or first login after introduction of this feature:
|
# Do not send on first login or first login after introduction of this feature:
|
||||||
try:
|
with language(user.locale):
|
||||||
with language(user.locale):
|
mail(
|
||||||
mail(
|
user.email,
|
||||||
user.email,
|
_('Login from new source detected'),
|
||||||
_('Login from new source detected'),
|
'pretixcontrol/email/login_notice.txt',
|
||||||
'pretixcontrol/email/login_notice.txt',
|
{
|
||||||
{
|
'source': src,
|
||||||
'source': src,
|
'country': Country(str(country)).name if country else _('Unknown country'),
|
||||||
'country': Country(str(country)).name if country else _('Unknown country'),
|
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
'url': build_absolute_uri('control:user.settings')
|
||||||
'url': build_absolute_uri('control:user.settings')
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
user=user,
|
||||||
user=user,
|
locale=user.locale
|
||||||
locale=user.locale
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Not much we can do
|
|
||||||
|
|||||||
+1782
-1708
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+1840
-1725
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||||
@@ -153,16 +153,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr "طلبات مختارة"
|
msgstr "طلبات مختارة"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr "الطلبات المدفوعة"
|
msgstr "الطلبات المدفوعة"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr "إجمالي الإيرادات"
|
msgstr "إجمالي الإيرادات"
|
||||||
|
|
||||||
|
|||||||
+1782
-1708
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+1821
-1724
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
|
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
|
||||||
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
|
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
|
||||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||||
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr "El mètode de pagament no està disponible"
|
msgstr "El mètode de pagament no està disponible"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr "Comanda realitzada"
|
msgstr "Comanda realitzada"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr "Comandes pagades"
|
msgstr "Comandes pagades"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr "Facturació total"
|
msgstr "Facturació total"
|
||||||
|
|
||||||
|
|||||||
+1904
-1834
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: 2025-09-08 18:57+0000\n"
|
"PO-Revision-Date: 2026-01-08 04:00+0000\n"
|
||||||
"Last-Translator: Alois Pospíšil <alois.pospisil@gmail.com>\n"
|
"Last-Translator: Jiří Pastrňák <jiri@pastrnak.email>\n"
|
||||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||||
"cs/>\n"
|
"cs/>\n"
|
||||||
"Language: cs\n"
|
"Language: cs\n"
|
||||||
@@ -17,7 +17,7 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
|
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
|
||||||
"X-Generator: Weblate 5.13\n"
|
"X-Generator: Weblate 5.15.1\n"
|
||||||
|
|
||||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
||||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
||||||
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr "Způsob platby není k dispozici"
|
msgstr "Způsob platby není k dispozici"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr "Zadané objednávky"
|
msgstr "Zadané objednávky"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr "Zaplacené objednávky"
|
msgstr "Zaplacené objednávky"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr "Celkové příjmy"
|
msgstr "Celkové příjmy"
|
||||||
|
|
||||||
@@ -416,7 +426,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: pretix/static/pretixbase/js/asynctask.js:276
|
#: pretix/static/pretixbase/js/asynctask.js:276
|
||||||
msgid "If this takes longer than a few minutes, please contact us."
|
msgid "If this takes longer than a few minutes, please contact us."
|
||||||
msgstr ""
|
msgstr "Pokud akce trvá déle než několik minut, kontaktujte nás."
|
||||||
|
|
||||||
#: pretix/static/pretixbase/js/asynctask.js:331
|
#: pretix/static/pretixbase/js/asynctask.js:331
|
||||||
msgid "Close message"
|
msgid "Close message"
|
||||||
@@ -734,7 +744,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:49
|
#: pretix/static/pretixpresale/js/ui/cart.js:49
|
||||||
msgid "Cart expired"
|
msgid "Cart expired"
|
||||||
msgstr "Nákupní košík vypršel"
|
msgstr "Rezervace košíku vypršela"
|
||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:58
|
#: pretix/static/pretixpresale/js/ui/cart.js:58
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:84
|
#: pretix/static/pretixpresale/js/ui/cart.js:84
|
||||||
@@ -753,27 +763,23 @@ msgstr[2] ""
|
|||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:83
|
#: pretix/static/pretixpresale/js/ui/cart.js:83
|
||||||
msgid "Your cart has expired."
|
msgid "Your cart has expired."
|
||||||
msgstr "Nákupní košík vypršel."
|
msgstr "Platnost rezervace vašeho košíku vypršela."
|
||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:86
|
#: pretix/static/pretixpresale/js/ui/cart.js:86
|
||||||
#, fuzzy
|
|
||||||
#| msgid ""
|
|
||||||
#| "The items in your cart are no longer reserved for you. You can still "
|
|
||||||
#| "complete your order as long as they’re available."
|
|
||||||
msgid ""
|
msgid ""
|
||||||
"The items in your cart are no longer reserved for you. You can still "
|
"The items in your cart are no longer reserved for you. You can still "
|
||||||
"complete your order as long as they're available."
|
"complete your order as long as they're available."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Produkty v nákupním košíku již nejsou pro vás rezervovány. Pokud je lístek "
|
"Produkty v nákupním košíku již nejsou rezervovány. Svou objednávku se přesto "
|
||||||
"stále dostupný, můžete objednávku dokončit."
|
"můžete pokusit dokončit, některé položky však už nemusí být dostupné."
|
||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:87
|
#: pretix/static/pretixpresale/js/ui/cart.js:87
|
||||||
msgid "Do you want to renew the reservation period?"
|
msgid "Do you want to renew the reservation period?"
|
||||||
msgstr ""
|
msgstr "Chcete obnovit rezervaci košíku?"
|
||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/cart.js:90
|
#: pretix/static/pretixpresale/js/ui/cart.js:90
|
||||||
msgid "Renew reservation"
|
msgid "Renew reservation"
|
||||||
msgstr ""
|
msgstr "Obnovit rezervaci"
|
||||||
|
|
||||||
#: pretix/static/pretixpresale/js/ui/main.js:194
|
#: pretix/static/pretixpresale/js/ui/main.js:194
|
||||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||||
|
|||||||
+1784
-1708
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
+1851
-1745
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: \n"
|
"Project-Id-Version: \n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: 2024-07-10 15:00+0000\n"
|
"PO-Revision-Date: 2024-07-10 15:00+0000\n"
|
||||||
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
|
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
|
||||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||||
@@ -154,16 +154,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr "Betalingsmetode er ikke tilgængelig"
|
msgstr "Betalingsmetode er ikke tilgængelig"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr "Afgivne bestillinger"
|
msgstr "Afgivne bestillinger"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr "Betalte bestillinger"
|
msgstr "Betalte bestillinger"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr "Omsætning i alt"
|
msgstr "Omsætning i alt"
|
||||||
|
|
||||||
|
|||||||
+1845
-1724
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: \n"
|
"Project-Id-Version: \n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
"POT-Creation-Date: 2026-01-26 09:10+0000\n"
|
||||||
"PO-Revision-Date: 2025-10-29 09:24+0000\n"
|
"PO-Revision-Date: 2025-10-29 09:24+0000\n"
|
||||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||||
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
|
|||||||
msgstr "Zahlungsmethode nicht verfügbar"
|
msgstr "Zahlungsmethode nicht verfügbar"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Placed orders"
|
msgid "Placed orders"
|
||||||
msgstr "Getätigte Bestellungen"
|
msgstr "Getätigte Bestellungen"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
|
||||||
msgid "Paid orders"
|
msgid "Paid orders"
|
||||||
msgstr "Bezahlte Bestellungen"
|
msgstr "Bezahlte Bestellungen"
|
||||||
|
|
||||||
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (ordered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
|
||||||
|
msgid "Attendees (paid)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
|
||||||
msgid "Total revenue"
|
msgid "Total revenue"
|
||||||
msgstr "Gesamtumsatz"
|
msgstr "Gesamtumsatz"
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user