Pluggable invoice transmission methods (#5020)

* Flexible invoice transmission

* UI work

* Add peppol and output

* API support

* Profile integration

* Simplify form for individuals

* Remove sent_to_customer usage

* more steps

* Revert "Bank transfer: Allow to send the invoice direclty to the accounting department (#2975)"

This reverts commit cea6c340be.

* minor fixes

* Fixes after rebase

* update stati

* Backend view

* Transmit and show status

* status, retransmission

* API retransmission

* More fields

* API docs

* Plugin docs

* Update migration

* Add missing license headers

* Remove dead code, fix current tests

* Run isort

* Update regex

* Rebase migration

* Fix migration

* Add tests, fix bugs

* Rebase migration

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Apply suggestion from @luelista

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

* Make migration reversible

* Add TransmissionType.enforce_transmission

* Fix registries API usage after rebase

* Remove code I forgot to delete

* Update transmission status display depending on type

* Add testmode_supported

* Update src/pretix/static/pretixbase/js/addressform.js

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

* Update src/pretix/static/pretixbase/js/addressform.js

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

* Update src/pretix/static/pretixbase/js/addressform.js

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

* New mechanism for non-required invoice forms

* Update src/pretix/base/invoicing/transmission.py

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

* Declare testmode_supported for email

* Make transmission_email_other an implementation detail

* Fix failing tests and add new ones

* Update src/pretix/base/services/invoices.py

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

* Add emails to email history

* Fix comma error

* More generic default email text

* Cleanup

* Remove "email invoices" button and refine logic

* Rebase migration

* Fix edge case

---------

Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
Raphael Michel
2025-08-19 17:59:45 +02:00
committed by GitHub
co-authored by luelista
parent 37910f6037
commit 05c74b7ad6
65 changed files with 4514 additions and 1825 deletions
+160 -2
View File
@@ -26,6 +26,8 @@ invoice_from_country string Sender address:
invoice_from_tax_id string Sender address: Local Tax ID
invoice_from_vat_id string Sender address: EU VAT ID
invoice_to string Full recipient address
invoice_to_is_business boolean Recipient address: Business vs individual (``null`` for
invoices created before pretix 2025.6).
invoice_to_company string Recipient address: Company name
invoice_to_name string Recipient address: Person name
invoice_to_street string Recipient address: Address lines
@@ -35,6 +37,7 @@ invoice_to_state string Recipient addre
invoice_to_country string Recipient address: Country code
invoice_to_vat_id string Recipient address: EU VAT ID
invoice_to_beneficiary string Invoice beneficiary
invoice_to_transmission_info object Additional transmission info (see :ref:`rest-transmission-types`)
custom_field string Custom invoice address field
date date Invoice date
refers string Invoice number of an invoice this invoice refers to
@@ -110,6 +113,12 @@ foreign_currency_rate decimal (string) If ``foreign_cu
foreign_currency_rate_date date If ``foreign_currency_rate`` is set, this signifies the
date at which the currency rate was obtained.
internal_reference string Customer's reference to be printed on the invoice.
transmission_type string Requested transmission channel (see :ref:`rest-transmission-types`)
transmission_provider string Selected transmission provider (depends on installed
plugins). ``null`` if not yet chosen.
transmission_status string Transmission status, one of ``unknown`` (pre-2025.6),
``pending``, ``inflight``, ``failed``, and ``completed``.
transmission_date datetime Time of last change in transmission status (may be ``null``).
===================================== ========================== =======================================================
@@ -121,6 +130,76 @@ internal_reference string Customer's refe
The ``tax_code`` attribute has been added.
.. versionchanged:: 2025.6
The attributes ``invoice_to_is_business``, ``invoice_to_transmission_info``, ``transmission_type``,
``transmission_provider``, ``transmission_status``, and ``transmission_date`` have been added.
.. _`rest-transmission-types`:
Transmission types
------------------
pretix supports multiple ways to transmit an invoice from the organizer to the invoice recipient.
For each transmission type, different fields are supported in the ``transmission_info`` object of the
invoice address. Currently, pretix supports the following transmission types:
Email
"""""
The identifier ``"email"`` represents the transmission of PDF invoices through email.
This is the default transmission type in pretix and has some special behavior for backwards compatibility.
Transmission is always executed through the provider ``"email_pdf"``.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_email_address string Optional. An email address other than the order address
that the invoice should be sent to.
Business customers only.
===================================== ========================== =======================================================
PEPPOL
""""""
The identifier ``"peppol"`` represents the transmission of XML invoices through the `PEPPOL`_ network.
This is only available for business addresses.
This is not supported by pretix out of the box and requires the use of a suitable plugin.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_peppol_participant_id string Required. The PEPPOL participant ID of the recipient.
===================================== ========================== =======================================================
Italian Exchange System
"""""""""""""""""""""""
The identifier ``"it_sdi"`` represents the transmission of XML invoices through the `Sistema di Interscambio`_ network used in Italy.
This is only available for addresses with country ``"IT"``.
This is not supported by pretix out of the box and requires the use of a suitable plugin.
The ``transmission_info`` object may contain the following properties:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
transmission_it_sdi_codice_fiscale string Required for non-business address. Fiscal code of the
recipient.
transmission_it_sdi_pec string Required for business addresses. Address for certified
electronic mail.
transmission_it_sdi_recipient_code string Required for businesses. SdI recipient code.
===================================== ========================== =======================================================
If this type is selected, ``vat_id`` is required for business addresses.
List of all invoices
--------------------
@@ -164,6 +243,7 @@ List of all invoices
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_is_business": true,
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
@@ -172,6 +252,7 @@ List of all invoices
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": null,
"date": "2017-12-01",
"refers": null,
@@ -204,7 +285,11 @@ List of all invoices
],
"foreign_currency_display": "PLN",
"foreign_currency_rate": "4.2408",
"foreign_currency_rate_date": "2017-07-24"
"foreign_currency_rate_date": "2017-07-24",
"transmission_type": "email",
"transmission_provider": "email_pdf",
"transmission_status": "completed",
"transmission_date": "2017-07-24T10:00:00Z"
}
]
}
@@ -304,6 +389,7 @@ Fetching individual invoices
"invoice_from_vat_id":"",
"invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT-ID: EU123456789",
"invoice_to_company": "Sample company",
"invoice_to_is_business": true,
"invoice_to_name": "John Doe",
"invoice_to_street": "Test street 12",
"invoice_to_zipcode": "12345",
@@ -312,6 +398,7 @@ Fetching individual invoices
"invoice_to_country": "TE",
"invoice_to_vat_id": "EU123456789",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": null,
"date": "2017-12-01",
"refers": null,
@@ -344,7 +431,11 @@ Fetching individual invoices
],
"foreign_currency_display": "PLN",
"foreign_currency_rate": "4.2408",
"foreign_currency_rate_date": "2017-07-24"
"foreign_currency_rate_date": "2017-07-24",
"transmission_type": "email",
"transmission_provider": "email_pdf",
"transmission_status": "completed",
"transmission_date": "2017-07-24T10:00:00Z"
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -449,3 +540,70 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
Transmitting invoices
---------------------
Invoices are transmitted automatically when created during order creation or payment receipt,
but in other cases transmission may need to be triggered manually.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/transmit/
Transmits the invoice to the recipient, but only if it is in ``pending`` state.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/transmit/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to transmit
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
:statuscode 409: The invoice is currently in transmission
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/retransmit/
Transmits the invoice to the recipient even if transmission was already attempted previously.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/retransmit/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/pdf
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to transmit
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to transmit this invoice **or** the invoice may not be transmitted
:statuscode 409: The invoice is currently in transmission
.. _PEPPOL: https://en.wikipedia.org/wiki/PEPPOL
.. _Sistema di Interscambio: https://it.wikipedia.org/wiki/Fattura_elettronica_in_Italia
+18 -3
View File
@@ -65,11 +65,16 @@ invoice_address object Invoice address
├ state string Customer state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US.
├ internal_reference string Customer's internal reference to be printed on the invoice
├ custom_field string Custom invoice address field
├ vat_id string Customer VAT ID
vat_id_validated string ``true``, if the VAT ID has been validated against the
vat_id_validated string ``true``, if the VAT ID has been validated against the
EU VAT service and validation was successful. This only
happens in rare cases.
├ transmission_type string Transmission channel for invoice (see also :ref:`rest-transmission-types`).
Defaults to ``email``.
└ transmission_info object Transmission-channel specific information (or ``null``).
See also :ref:`rest-transmission-types`.
positions list of objects List of order positions (see below). By default, only
non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only
@@ -142,6 +147,10 @@ plugin_data object Additional data
The ``plugin_data`` attribute has been added.
.. versionchanged:: 2025.6
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
.. _order-position-resource:
Order position resource
@@ -368,7 +377,9 @@ List of all orders
"state": "",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": false,
"transmission_type": "email",
"transmission_info": {}
},
"positions": [
{
@@ -610,7 +621,9 @@ Fetching individual orders
"state": "",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": false,
"transmission_type": "email",
"transmission_info": {}
},
"positions": [
{
@@ -1017,6 +1030,8 @@ Creating orders
* ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
* ``transmission_type`` (optional, defaults to ``email``)
* ``transmission_info`` (optional, see also :ref:`rest-transmission-types`)
* ``positions``
+1
View File
@@ -13,6 +13,7 @@ Contents:
email
placeholder
invoice
invoicetransmission
shredder
import
customview
@@ -0,0 +1,65 @@
.. highlight:: python
:linenothreshold: 5
Writing an invoice transmission plugin
======================================
An invoice transmission provider transports an invoice from the sender to the recipient.
There are pre-defined types of invoice transmission in pretix, currently ``"email"``, ``"peppol"``, and ``"it_sdi"``.
You can find more information about them at :ref:`rest-transmission-types`.
New transmission types can not be added by plugins but need to be added to pretix itself.
However, plugins can provide implementations for the actual transmission.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Output registration
-------------------
New invoice transmission providers can be registered through the :ref:`registry <registries>` mechanism
.. code-block:: python
from pretix.base.invoicing.transmission import transmission_providers, TransmissionProvider
@transmission_providers.new()
class SdiTransmissionProvider(TransmissionProvider):
identifier = "fatturapa_providerabc"
type = "it_sdi"
verbose_name = _("FatturaPA through provider ABC")
...
The provider class
------------------
.. class:: pretix.base.invoicing.transmission.TransmissionProvider
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: type
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: priority
.. autoattribute:: testmode_supported
.. automethod:: is_ready
This is an abstract method, you **must** override this!
.. automethod:: is_available
This is an abstract method, you **must** override this!
.. automethod:: transmit
This is an abstract method, you **must** override this!
.. automethod:: settings_url
+3 -91
View File
@@ -19,45 +19,16 @@
# 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/>.
#
from django import forms
from django.conf import settings
from django.http import QueryDict
from pytz import common_timezones
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
self.form_field = kwargs.pop('form_field')
super().__init__(*args, **kwargs)
def to_representation(self, value):
return self.form_field.widget.format_value(value)
def to_internal_value(self, data):
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
d = self.form_field.clean(d)
return d
simple_mappings = (
(forms.DateField, serializers.DateField, ()),
(forms.TimeField, serializers.TimeField, ()),
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
(forms.DateTimeField, serializers.DateTimeField, ()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, ()),
(forms.IntegerField, serializers.IntegerField, ()),
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
from pretix.base.timeframes import SerializerDateFrameField
class SerializerDescriptionField(serializers.Field):
@@ -81,13 +52,6 @@ class ExporterSerializer(serializers.Serializer):
input_parameters = SerializerDescriptionField(source='_serializer')
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
if isinstance(value, int):
return value
return super().to_representation(value)
class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
ex = kwargs.pop('exporter')
@@ -102,59 +66,7 @@ class JobRunSerializer(serializers.Serializer):
many=True
)
for k, v in ex.export_form_fields.items():
for m_from, m_to, m_kwargs in simple_mappings:
if isinstance(v, m_from):
self.fields[k] = m_to(
required=v.required,
allow_null=not v.required,
validators=v.validators,
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
)
break
if isinstance(v, forms.NullBooleanField):
self.fields[k] = serializers.BooleanField(
required=v.required,
allow_null=True,
validators=v.validators,
)
if isinstance(v, forms.ModelMultipleChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
many=True
)
elif isinstance(v, forms.ModelChoiceField):
self.fields[k] = PrimaryKeyRelatedField(
queryset=v.queryset,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.MultipleChoiceField):
self.fields[k] = serializers.MultipleChoiceField(
choices=v.choices,
required=v.required,
allow_empty=not v.required,
validators=v.validators,
)
elif isinstance(v, forms.ChoiceField):
self.fields[k] = serializers.ChoiceField(
choices=v.choices,
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, DateFrameField):
self.fields[k] = SerializerDateFrameField(
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
else:
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
self.fields[k] = form_field_to_serializer_field(v)
def to_internal_value(self, data):
if isinstance(data, QueryDict):
+115
View File
@@ -0,0 +1,115 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
from django import forms
from rest_framework import serializers
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
simple_mappings = (
(forms.DateField, serializers.DateField, ()),
(forms.TimeField, serializers.TimeField, ()),
(forms.SplitDateTimeField, serializers.DateTimeField, ()),
(forms.DateTimeField, serializers.DateTimeField, ()),
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
(forms.FloatField, serializers.FloatField, ()),
(forms.IntegerField, serializers.IntegerField, ()),
(forms.EmailField, serializers.EmailField, ()),
(forms.UUIDField, serializers.UUIDField, ()),
(forms.URLField, serializers.URLField, ()),
(forms.BooleanField, serializers.BooleanField, ()),
)
class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_representation(self, value):
if isinstance(value, int):
return value
return super().to_representation(value)
class FormFieldWrapperField(serializers.Field):
def __init__(self, *args, **kwargs):
self.form_field = kwargs.pop('form_field')
super().__init__(*args, **kwargs)
def to_representation(self, value):
return self.form_field.widget.format_value(value)
def to_internal_value(self, data):
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
d = self.form_field.clean(d)
return d
def form_field_to_serializer_field(field):
for m_from, m_to, m_kwargs in simple_mappings:
if isinstance(field, m_from):
return m_to(
required=field.required,
allow_null=not field.required,
validators=field.validators,
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
)
if isinstance(field, forms.NullBooleanField):
return serializers.BooleanField(
required=field.required,
allow_null=True,
validators=field.validators,
)
if isinstance(field, forms.ModelMultipleChoiceField):
return PrimaryKeyRelatedField(
queryset=field.queryset,
required=field.required,
allow_empty=not field.required,
validators=field.validators,
many=True
)
elif isinstance(field, forms.ModelChoiceField):
return PrimaryKeyRelatedField(
queryset=field.queryset,
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
elif isinstance(field, forms.MultipleChoiceField):
return serializers.MultipleChoiceField(
choices=field.choices,
required=field.required,
allow_empty=not field.required,
validators=field.validators,
)
elif isinstance(field, forms.ChoiceField):
return serializers.ChoiceField(
choices=field.choices,
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
elif isinstance(field, DateFrameField):
return SerializerDateFrameField(
required=field.required,
allow_null=not field.required,
validators=field.validators,
)
else:
return FormFieldWrapperField(form_field=field, required=field.required, allow_null=not field.required)
+58 -5
View File
@@ -42,6 +42,7 @@ from rest_framework.reverse import reverse
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
@@ -49,6 +50,7 @@ from pretix.api.serializers.item import (
from pretix.api.signals import order_api_details, orderposition_api_details
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
@@ -102,6 +104,13 @@ class CountryField(serializers.Field):
return str(src) if src else None
class TransmissionInfoSerializer(serializers.Serializer):
def __init__(self, *args, transmission_type, **kwargs):
super().__init__(*args, **kwargs)
for k, v in transmission_type.invoice_address_form_fields.items():
self.fields[k] = form_field_to_serializer_field(v)
class InvoiceAddressSerializer(I18nAwareModelSerializer):
country = CompatibleCountryField(source='*')
name = serializers.CharField(required=False)
@@ -109,7 +118,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference')
'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference', 'transmission_type',
'transmission_info')
read_only_fields = ('last_modified',)
def __init__(self, *args, **kwargs):
@@ -147,6 +157,48 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
if data.get("transmission_type"):
for t in get_transmission_types():
if data.get("transmission_type") == t.identifier:
if not t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The selected transmission type is not available for this country or address type."
})
ts = TransmissionInfoSerializer(transmission_type=t, data=data.get("transmission_info", {}))
try:
ts.is_valid(raise_exception=True)
except ValidationError as e:
raise ValidationError(
{"transmission_info": e.detail}
)
data["transmission_info"] = ts.validated_data
required_fields = t.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
for r in required_fields:
if r in self.fields:
if not data.get(r):
raise ValidationError(
{r: "This field is required for the selected type of invoice transmission."}
)
else:
if not ts.validated_data.get(r):
raise ValidationError(
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
)
break # do not call else branch of for loop
elif t.exclusive:
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
t.identifier,
)
})
else:
raise ValidationError(
{"transmission_type": "Unknown transmission type."}
)
return data
@@ -1725,12 +1777,13 @@ class InvoiceSerializer(I18nAwareModelSerializer):
model = Invoice
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
'invoice_to', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street', 'invoice_to_zipcode',
'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id', 'invoice_to_beneficiary',
'custom_field', 'date', 'refers', 'locale',
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
'introductory_text', 'additional_text', 'payment_provider_text', 'payment_provider_stamp',
'footer_text', 'lines', 'foreign_currency_display', 'foreign_currency_rate',
'foreign_currency_rate_date', 'internal_reference')
'foreign_currency_rate_date', 'internal_reference', 'transmission_type', 'transmission_provider',
'transmission_status', 'transmission_date')
class OrderPaymentCreateSerializer(I18nAwareModelSerializer):
+48 -3
View File
@@ -88,7 +88,7 @@ from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
regenerate_invoice, transmit_invoice,
)
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
@@ -1891,6 +1891,12 @@ class RetryException(APIException):
default_code = 'retry_later'
class CurrentlyInflightException(APIException):
status_code = 409
default_detail = 'The requested action is already in progress.'
default_code = 'currently_inflight'
class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = InvoiceSerializer
queryset = Invoice.objects.none()
@@ -1939,13 +1945,52 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):
invoice = self.get_object()
if invoice.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING:
raise PermissionDenied('The invoice is not in pending state.')
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, False))
return Response(status=204)
@action(detail=True, methods=['POST'])
def retransmit(self, request, **kwargs):
invoice = self.get_object()
if invoice.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
with transaction.atomic(durable=True):
invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice.pk)
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
raise CurrentlyInflightException()
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
'pretix.event.order.invoice.retransmitted',
user=self.request.user,
auth=self.request.auth,
data={
'invoice': invoice.pk,
'full_invoice_no': invoice.full_invoice_no,
}
)
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True))
return Response(status=204)
@action(detail=True, methods=['POST'])
def regenerate(self, request, **kwargs):
inv = self.get_object()
if inv.canceled:
raise ValidationError('The invoice has already been canceled.')
if not inv.event.settings.invoice_regenerate_allowed:
raise PermissionDenied('Invoices may not be changed after they are created.')
if not inv.regenerate_allowed:
raise PermissionDenied('Invoice may not be regenerated.')
elif inv.shredded:
raise PermissionDenied('The invoice file is no longer stored on the server.')
elif inv.sent_to_organizer:
+1 -1
View File
@@ -43,7 +43,7 @@ class PretixBaseConfig(AppConfig):
from . import exporter # NOQA
from . import payment # NOQA
from . import exporters # NOQA
from . import invoice # NOQA
from .invoicing import pdf, transmission, email, peppol, national # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, checkin, currencies, datasync, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
+107 -6
View File
@@ -54,7 +54,6 @@ from django.core.validators import (
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -78,6 +77,7 @@ from pretix.base.forms.widgets import (
from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
@@ -736,7 +736,7 @@ class BaseQuestionsForm(forms.Form):
initial=country,
widget=forms.Select(attrs={
'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
'data-trigger-address-info': 'on',
}),
)
c = [('', '---')]
@@ -1142,11 +1142,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
if kwargs.get('instance'):
kwargs['initial'].update(kwargs['instance'].transmission_info or {})
kwargs['initial']['transmission_type'] = kwargs['instance'].transmission_type
super().__init__(*args, **kwargs)
# Individuals do not have a company name or VAT ID
self.fields["company"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
# The internal reference is a very business-specific field and might confuse non-business users
self.fields["internal_reference"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
if not self.ask_vat_id:
del self.fields['vat_id']
elif self.validate_vat_id:
@@ -1162,8 +1170,20 @@ class BaseInvoiceAddressForm(forms.ModelForm):
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
])
transmission_type_choices = [
(t.identifier, t.public_name) for t in get_transmission_types()
]
if not self.address_required or self.all_optional:
transmission_type_choices.insert(0, ("-", _("No invoice requested")))
self.fields['transmission_type'] = forms.ChoiceField(
label=_('Invoice transmission method'),
choices=transmission_type_choices
)
self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
self.fields['country'].widget.attrs['data-trigger-address-info'] = 'on'
self.fields['is_business'].widget.attrs['data-trigger-address-info'] = 'on'
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
c = [('', '---')]
fprefix = self.prefix + '-' if self.prefix else ''
@@ -1250,6 +1270,44 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + autocomplete
# Add transmission type specific fields
for transmission_type in get_transmission_types():
for k, f in transmission_type.invoice_address_form_fields.items():
if (
transmission_type.identifier == "email" and
k in ("transmission_email_other", "transmission_email_address") and
(
event.settings.invoice_generate == "False" or
not event.settings.invoice_email_attachment
)
):
# This looks like a very unclean hack (and probably really is one), but hear me out:
# With pretix 2025.7, we introduced invoice transmission types and added the "send to another email"
# feature for the email provider. This feature was previously part of the bank transfer payment
# provider and opt-in. With this change, this feature becomes available for all pretix shops, which
# we think is a good thing in the long run as it is an useful feature for every business customer.
# However, there's two scenarios where it might be bad that we add it without opt-in:
# - When the organizer has turned off invoice generation in pretix and is collecting invoice information
# only for other reasons or to later create invoices with a separate software. In this case it
# would be very bad for the user to be able to ask for the invoice to be sent somewhere else, and
# that information then be ignored because the organizer has not updated their process.
# - When the organizer has intentionally turned off invoices being attached to emails, because that
# would somehow be a contradiction.
# Now, the obvious solution would be to make the TransmissionType.invoice_address_form_fields property
# a function that depends on the event as an input. However, I believe this is the wrong approach
# over the long term. As a generalized concept, we DO want invoice address collection to be
# *independent* of event settings, in order to (later) e.g. implement invoice address editing within
# customer accounts. Hence, this hack directly in the form to provide (some) backwards compatibility
# only for the default transmission type "email".
continue
self.fields[k] = f
f._required = f.required
f.required = False
f.widget.is_required = False
if 'required' in f.widget.attrs:
del f.widget.attrs['required']
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
@@ -1277,11 +1335,23 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.name_parts = data.get('name_parts')
if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and name_parts_is_empty(data.get('name_parts', {})):
form_is_empty = all(
not v for k, v in data.items()
if k not in ('is_business', 'country', 'name_parts', 'transmission_type') and not k.startswith("transmission_")
) and name_parts_is_empty(data.get('name_parts', {}))
if form_is_empty:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if data.get('transmission_type') == "-":
data['transmission_type'] = 'email' # our actual default for now, we can revisit this later
else:
if data.get('transmission_type') == "-":
raise ValidationError(
{"transmission_type": _("If you enter an invoice address, you also need to select an invoice "
"transmission method.")}
)
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass
@@ -1303,6 +1373,37 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else:
self.instance.vat_id_validated = False
for transmission_type in get_transmission_types():
if transmission_type.identifier == data.get("transmission_type"):
if not transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": _("The selected transmission type is not available in your country or for "
"your type of address.")
})
required_fields = transmission_type.invoice_address_form_fields_required(data.get("country"), data.get("is_business"))
for r in required_fields:
if r not in self.fields:
logger.info(f"Transmission type {transmission_type.identifier} required field {r} which is not available.")
raise ValidationError(
_("The selected type of invoice transmission requires a field that is currently not "
"available, please reach out to the organizer.")
)
if not data.get(r):
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")})
self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = {
k: data.get(k) for k in transmission_type.invoice_address_form_fields
}
elif transmission_type.exclusive:
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
transmission_type.public_name,
)
})
class BaseInvoiceNameForm(BaseInvoiceAddressForm):
def __init__(self, *args, **kwargs):
+15 -1084
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
+173
View File
@@ -0,0 +1,173 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
from django import forms
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import (
TransmissionProvider, TransmissionType, transmission_providers,
transmission_types,
)
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers.format import format_map
@transmission_types.new()
class EmailTransmissionType(TransmissionType):
identifier = "email"
verbose_name = _("Email")
priority = 1000
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_email_other": forms.BooleanField(
label=_("Email invoice directly to accounting department"),
help_text=_("If not selected, the invoice will be sent to you using the email address listed above."),
required=False,
),
"transmission_email_address": forms.EmailField(
label=_("Email address for invoice"),
widget=forms.EmailInput(
attrs={"data-display-dependency": "#id_transmission_email_other"}
)
)
}
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
if is_business:
# We don't want ask non-business users if they have an accounting department ;)
return {"transmission_email_other", "transmission_email_address"}
return set()
def is_available(self, event, country: Country, is_business: bool):
# Skip availability check since provider is always available and we do not want to end up without invoice
# transmission type
return True
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return {
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
"transmission_email_address": transmission_info.get("transmission_email_address"),
}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
if form_data.get("transmission_email_other") and form_data.get("transmission_email_address"):
return {
"transmission_email_address": form_data["transmission_email_address"],
}
return {}
@transmission_providers.new()
class EmailTransmissionProvider(TransmissionProvider):
identifier = "email_pdf"
type = "email"
verbose_name = _("PDF via email")
priority = 1000
testmode_supported = True
def is_ready(self, event) -> bool:
return True
def is_available(self, event, country: Country, is_business: bool) -> bool:
return True
def transmit(self, invoice: Invoice):
info = (invoice.invoice_to_transmission_info or {})
if info.get("transmission_email_address"):
recipient = info["transmission_email_address"]
else:
recipient = invoice.order.email
if not recipient:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"reason": "no_recipient",
},
}
)
return
with language(invoice.order.locale, invoice.order.event.settings.region):
context = get_email_context(
event=invoice.order.event,
order=invoice.order,
invoice=invoice,
event_or_subevent=invoice.order.event,
invoice_address=getattr(invoice.order, 'invoice_address', None) or InvoiceAddress()
)
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)
try:
# Do not set to completed because that is done by the email sending task
subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
[recipient],
subject,
template,
context=context,
event=invoice.order.event,
locale=invoice.order.locale,
order=invoice.order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
invoice.order.log_action(
'pretix.event.order.email.invoice',
user=None,
auth=None,
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
)
+75
View File
@@ -0,0 +1,75 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
from django import forms
from django.core.validators import RegexValidator
from django.utils.translation import pgettext_lazy
from django_countries.fields import Country
from localflavor.it.forms import ITSocialSecurityNumberField
from pretix.base.invoicing.transmission import (
TransmissionType, transmission_types,
)
@transmission_types.new()
class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True
def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business)
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_it_sdi_codice_fiscale": ITSocialSecurityNumberField(
label=pgettext_lazy("italian_invoice", "Fiscal code"),
required=False,
),
"transmission_it_sdi_pec": forms.EmailField(
label=pgettext_lazy("italian_invoice", "Address for certified electronical mail"),
widget=forms.EmailInput()
),
"transmission_it_sdi_recipient_code": forms.CharField(
label=pgettext_lazy("italian_invoice", "Recipient code"),
validators=[
RegexValidator("^[A-Z0-9]{6,7}$")
]
),
}
def invoice_address_form_fields_visible(self, country: Country, is_business: bool):
if is_business:
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
return {"transmission_it_sdi_codice_fiscale", "transmission_it_sdi_pec"}
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
base = {
"street", "zipcode", "city", "state", "country",
}
if is_business:
return base | {"company", "vat_id", "transmission_it_sdi_pec", "transmission_it_sdi_recipient_code"}
return base | {"transmission_it_sdi_codice_fiscale"}
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 re
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country
from pretix.base.invoicing.transmission import (
TransmissionType, transmission_types,
)
class PeppolIdValidator:
regex_rules = {
# Source: https://docs.peppol.eu/edelivery/codelists/old/v8.5/Peppol%20Code%20Lists%20-%20Participant%20identifier%20schemes%20v8.5.html
"0002": "[0-9]{9}([0-9]{5})?",
"0007": "[0-9]{10}",
"0009": "[0-9]{14}",
"0037": "(0037)?[0-9]{7}-?[0-9][0-9A-Z]{0,5}",
"0060": "[0-9]{9}",
"0088": "[0-9]{13}",
"0096": "[0-9]{17}",
"0097": "[0-9]{11,16}",
"0106": "[0-9]{17}",
"0130": ".*",
"0135": ".*",
"0142": ".*",
"0151": "[0-9]{11}",
"0183": "CHE[0-9]{9}",
"0184": "DK[0-9]{8}([0-9]{2})?",
"0188": ".*",
"0190": "[0-9]{20}",
"0191": "[1789][0-9]{7}",
"0192": "[0-9]{9}",
"0193": ".{4,50}",
"0195": "[a-z]{2}[a-z]{3}([0-9]{8}|[0-9]{9}|[RST][0-9]{2}[a-z]{2}[0-9]{4})[0-9a-z]",
"0196": "[0-9]{10}",
"0198": "DK[0-9]{8}",
"0199": "[A-Z0-9]{18}[0-9]{2}",
"0020": "[0-9]{9}",
"0201": "[0-9a-zA-Z]{6}",
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
"0208": "0[0-9]{9}",
"0209": ".*",
"0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}",
"0212": "[0-9]{7}-[0-9]",
"0213": "FI[0-9]{8}",
"0205": "[A-Z0-9]+",
"0221": "T[0-9]{13}",
"0230": ".*",
"9901": ".*",
"9902": "[1-9][0-9]{7}",
"9904": "DK[0-9]{8}",
"9909": "NO[0-9]{9}MVA",
"9910": "HU[0-9]{8}",
"9912": "[A-Z]{2}[A-Z0-9]{,20}",
"9913": ".*",
"9914": "ATU[0-9]*",
"9915": "[A-Z][A-Z0-9]*",
"9916": ".*",
"9917": "[0-9]{10}",
"9918": "[A-Z]{2}[0-9]{2}[A-Z-0-9]{11,30}",
"9919": "[A-Z][0-9]{3}[A-Z][0-9]{3}[A-Z]",
"9920": ".*",
"9921": ".*",
"9922": ".*",
"9923": ".*",
"9924": ".*",
"9925": ".*",
"9926": ".*",
"9927": ".*",
"9928": ".*",
"9929": ".*",
"9930": ".*",
"9931": ".*",
"9932": ".*",
"9933": ".*",
"9934": ".*",
"9935": ".*",
"9936": ".*",
"9937": ".*",
"9938": ".*",
"9939": ".*",
"9940": ".*",
"9941": ".*",
"9942": ".*",
"9943": ".*",
"9944": ".*",
"9945": ".*",
"9946": ".*",
"9947": ".*",
"9948": ".*",
"9949": ".*",
"9950": ".*",
"9951": ".*",
"9952": ".*",
"9953": ".*",
"9954": ".*",
"9956": "0[0-9]{9}",
"9957": ".*",
"9959": ".*",
}
def __call__(self, value):
if ":" not in value:
raise ValidationError(_("A PEPPOL participant ID always starts with a prefix, followed by a colon (:)."))
prefix, second = value.split(":", 1)
if prefix not in self.regex_rules:
raise ValidationError(_("The PEPPOL participant ID prefix %(number)s is not known to our system. Please "
"reach out to us if you are sure this ID is correct."), params={"number": prefix})
if not re.match(self.regex_rules[prefix], second):
raise ValidationError(_("The PEPPOL participant ID does not match the validation rules for the prefix "
"%(number)s. Please reach out to us if you are sure this ID is correct."),
params={"number": prefix})
return value
@transmission_types.new()
class PeppolTransmissionType(TransmissionType):
identifier = "peppol"
verbose_name = "PEPPOL"
priority = 250
enforce_transmission = True
def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business)
@property
def invoice_address_form_fields(self) -> dict:
return {
"transmission_peppol_participant_id": forms.CharField(
label=_("PEPPOL participant ID"),
validators=[
PeppolIdValidator(),
]
),
}
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
base = {
"company", "street", "zipcode", "city", "country",
}
return base | {"transmission_peppol_participant_id"}
+246
View File
@@ -0,0 +1,246 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
from typing import Optional
from django_countries.fields import Country
from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.signals import EventPluginRegistry, Registry
class TransmissionType:
@property
def identifier(self) -> str:
"""
A short and unique identifier for this transmission type.
"""
raise NotImplementedError
@property
def verbose_name(self) -> str:
"""
A human-readable name for this transmission type to be shown internally in the backend.
"""
raise NotImplementedError
@property
def public_name(self) -> str:
"""
A human-readable name for this transmission type to be shown to the public.
By default, this is the same as ``verbose_name``
"""
return self.verbose_name
@property
def priority(self) -> int:
"""
Returns a priority that is used for sorting transmission type. Higher priority means higher up in the list.
Default to 100. Providers with same priority are sorted alphabetically.
"""
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
def enforce_transmission(self) -> bool:
"""
If a transmission type enforces transmission, every invoice created with this type will be transferred.
If not, the backend user is in some cases trusted to decide whether or not to transmit it.
"""
return False
def is_available(self, event, country: Country, is_business: bool) -> bool:
providers = transmission_providers.filter(type=self.identifier, active_in=event)
return any(
provider.is_available(event, country, is_business)
for provider, _ in providers
)
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set()
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
return set(self.invoice_address_form_fields.keys())
def validate_address(self, ia: InvoiceAddress):
pass
@property
def invoice_address_form_fields(self) -> dict:
"""
Return a set of form fields that **must** be prefixed with ``transmission_<identifier>_``.
"""
return {}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
return form_data
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info
class TransmissionProvider:
"""
Base class for a transmission provider. Should NOT hold internal state as the class is only
instantiated once and then shared between events and organizers.
"""
@property
def identifier(self):
"""
A short and unique identifier for this transmission provider.
This should only contain lowercase letters and underscores.
"""
raise NotImplementedError
@property
def type(self):
"""
Identifier of the transmission type this provider provides.
"""
raise NotImplementedError
@property
def verbose_name(self):
"""
A human-readable name for this transmission provider (can be localized).
"""
raise NotImplementedError
@property
def testmode_supported(self) -> bool:
"""
Whether testmode invoices may be passed to this provider.
"""
return False
def is_ready(self, event) -> bool:
"""
Return whether this provider has all required configuration to be used in this event.
"""
raise NotImplementedError
def is_available(self, event, country: Country, is_business: bool) -> bool:
"""
Return whether this provider may be used for an invoice for the given recipient country and address type.
"""
raise NotImplementedError
def transmit(self, invoice: Invoice):
"""
Transmit the invoice. The invoice passed as a parameter will be in status ``TRANSMISSION_STATUS_INFLIGHT``.
Invoices that stay in this state for more than 24h will be retried automatically. Implementations are expected to:
- Send the invoice.
- Update the ``transmission_status`` to `TRANSMISSION_STATUS_COMPLETED` or `TRANSMISSION_STATUS_FAILED`
after sending, as well as ``transmission_info`` with provider-specific data, and ``transmission_date`` to
the date and time of completion.
- Create a log entry of action type ``pretix.event.order.invoice.sent`` or
``pretix.event.order.invoice.sending_failed`` with the fields ``full_invoice_no``, ``transmission_provider``,
``transmission_type`` and a provider-specific ``data`` field.
Make sure to either handle ``invoice.order.testmode`` properly or set ``testmode_supported`` to ``False``.
"""
raise NotImplementedError
@property
def priority(self) -> int:
"""
Returns a priority that is used for sorting transmission providers. Higher priority will be chosen over
lower priority for transmission. Default to 100.
"""
return 100
def settings_url(self, event) -> Optional[str]:
"""
Return a URL to the settings page of this provider (if any).
"""
return None
class TransmissionProviderRegistry(EventPluginRegistry):
def __init__(self):
super().__init__({
'identifier': lambda o: getattr(o, 'identifier'),
'type': lambda o: getattr(o, 'type'),
})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, TransmissionProvider):
raise TypeError('Entries must be derived from TransmissionProvider')
if obj.type == "email" and not obj.__module__.startswith('pretix.base.'):
raise TypeError('No custom providers for email allowed')
return super().register(*objs)
class TransmissionTypeRegistry(Registry):
def __init__(self):
super().__init__({
'identifier': lambda o: getattr(o, 'identifier'),
})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, TransmissionType):
raise TypeError('Entries must be derived from TransmissionType')
if not obj.__module__.startswith('pretix.base.'):
raise TypeError('Plugins are currently not allowed to add transmission types')
return super().register(*objs)
"""
Registry for transmission providers.
Each entry in this registry should be an instance of a subclass of ``TransmissionProvider``.
They are annotated with their ``identifier``, ``type``, and the defining ``plugin``.
"""
transmission_providers = TransmissionProviderRegistry()
"""
Registry for transmission types.
Each entry in this registry should be an instance of a subclass of ``TransmissionType``.
They are annotated with their ``identifier``.
"""
transmission_types = TransmissionTypeRegistry()
def get_transmission_types():
return sorted(
transmission_types.registered_entries.keys(),
key=lambda t: (-t.priority, str(t.public_name)),
)
@@ -0,0 +1,75 @@
# Generated by Django 4.2.17 on 2025-04-21 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0287_organizer_plugins"),
]
operations = [
migrations.RenameField(
model_name="invoice",
old_name="sent_to_customer",
new_name="transmission_date",
),
migrations.AddField(
model_name="invoice",
name="invoice_to_transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_provider",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name="invoice",
name="transmission_status",
field=models.CharField(default="unknown", max_length=255),
),
migrations.AddField(
model_name="invoice",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="invoice",
name="invoice_to_is_business",
field=models.BooleanField(null=True),
),
migrations.RunSQL(
"UPDATE pretixbase_invoice SET transmission_status = 'completed' WHERE transmission_date IS NOT NULL",
migrations.RunSQL.noop,
),
migrations.AddField(
model_name="invoice",
name="transmission_type",
field=models.CharField(default="email", max_length=255),
),
migrations.AddField(
model_name="invoiceaddress",
name="transmission_info",
field=models.JSONField(null=True),
),
migrations.AddField(
model_name="invoiceaddress",
name="transmission_type",
field=models.CharField(default="email", max_length=255),
),
migrations.RunSQL(
"UPDATE pretixbase_event_settingsstore SET key = 'mail_text_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_text'",
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_text' WHERE key = 'mail_text_order_invoice'",
),
migrations.RunSQL(
"UPDATE pretixbase_event_settingsstore SET key = 'mail_subject_order_invoice' WHERE key = 'payment_banktransfer_invoice_email_subject'",
"UPDATE pretixbase_event_settingsstore SET key = 'payment_banktransfer_invoice_email_subject' WHERE key = 'mail_subject_order_invoice'",
),
]
+48 -3
View File
@@ -42,7 +42,7 @@ from django.db.models.functions import Cast
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import pgettext
from django.utils.translation import gettext_lazy as _, pgettext
from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
@@ -110,6 +110,21 @@ class Invoice(models.Model):
:param file: The filename of the rendered invoice
:type file: File
"""
TRANSMISSION_STATUS_PENDING = "pending"
TRANSMISSION_STATUS_INFLIGHT = "inflight"
TRANSMISSION_STATUS_COMPLETED = "completed"
TRANSMISSION_STATUS_FAILED = "failed"
TRANSMISSION_STATUS_UNKNOWN = "unknown"
TRANSMISSION_STATUS_TESTMODE_IGNORED = "testmode_ignored"
TRANSMISSION_STATUS_CHOICES = (
(TRANSMISSION_STATUS_PENDING, _("pending transmission")),
(TRANSMISSION_STATUS_INFLIGHT, _("currently being transmitted")),
(TRANSMISSION_STATUS_COMPLETED, _("transmitted")),
(TRANSMISSION_STATUS_FAILED, _("failed")),
(TRANSMISSION_STATUS_UNKNOWN, _("unknown")),
(TRANSMISSION_STATUS_TESTMODE_IGNORED, _("not transmitted due to test mode")),
)
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
@@ -131,6 +146,7 @@ class Invoice(models.Model):
invoice_to = models.TextField()
invoice_to_company = models.TextField(null=True)
invoice_to_is_business = models.BooleanField(null=True)
invoice_to_name = models.TextField(null=True)
invoice_to_street = models.TextField(null=True)
invoice_to_zipcode = models.CharField(max_length=190, null=True)
@@ -139,9 +155,11 @@ class Invoice(models.Model):
invoice_to_country = FastCountryField(null=True)
invoice_to_vat_id = models.TextField(null=True)
invoice_to_beneficiary = models.TextField(null=True)
invoice_to_transmission_info = models.JSONField(null=True, blank=True)
internal_reference = models.TextField(blank=True)
custom_field = models.CharField(max_length=255, null=True)
created = models.DateTimeField(auto_now_add=True, null=True) # null for backwards compatibility
date = models.DateField(default=today)
locale = models.CharField(max_length=50, default='en')
introductory_text = models.TextField(blank=True)
@@ -158,14 +176,28 @@ class Invoice(models.Model):
shredded = models.BooleanField(default=False)
# The field sent_to_organizer records whether this invocie was already sent to the organizer by a configured
# The field sent_to_organizer records whether this invoice was already sent to the organizer by a configured
# mechanism such as email.
# NULL: The cronjob that handles sending did not yet run.
# True: The invoice was sent.
# False: The invoice wasn't sent and never will, because sending was not configured at the time of the check.
sent_to_organizer = models.BooleanField(null=True, blank=True)
sent_to_customer = models.DateTimeField(null=True, blank=True)
transmission_type = models.CharField(
max_length=255,
default="email",
)
transmission_provider = models.CharField(
max_length=255,
null=True, blank=True,
)
transmission_status = models.CharField(
max_length=255,
choices=TRANSMISSION_STATUS_CHOICES,
default=TRANSMISSION_STATUS_UNKNOWN,
)
transmission_date = models.DateTimeField(null=True, blank=True)
transmission_info = models.JSONField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
@@ -323,6 +355,19 @@ class Invoice(models.Model):
def __str__(self):
return self.full_invoice_no
@property
def regenerate_allowed(self):
return self.transmission_status in (
Invoice.TRANSMISSION_STATUS_UNKNOWN,
Invoice.TRANSMISSION_STATUS_PENDING,
Invoice.TRANSMISSION_STATUS_FAILED,
) and self.event.settings.invoice_regenerate_allowed
@property
def transmission_type_instance(self):
from pretix.base.invoicing.transmission import transmission_types
return transmission_types.get(identifier=self.transmission_type)[0]
class InvoiceLine(models.Model):
"""
+32 -2
View File
@@ -1939,6 +1939,7 @@ class OrderPayment(models.Model):
ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True):
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, transmit_invoice,
)
from pretix.base.services.locking import LOCK_TRUST_WINDOW
@@ -1969,13 +1970,19 @@ class OrderPayment(models.Model):
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
transmit_invoice_task = invoice_transmission_separately(invoice)
transmit_invoice_mail = not transmit_invoice_task and self.order.event.settings.invoice_email_attachment and self.order.email
if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid:
self._send_paid_mail(invoice, user, mail_text)
self._send_paid_mail(invoice if transmit_invoice_mail else None, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
self._send_paid_mail_attendee(p, user)
if invoice and not transmit_invoice_mail:
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
@@ -2005,7 +2012,7 @@ class OrderPayment(models.Model):
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical
)
@@ -3293,6 +3300,9 @@ class InvoiceAddress(models.Model):
blank=True
)
transmission_type = models.CharField(max_length=255, default="email")
transmission_info = models.JSONField(null=True, blank=True)
objects = ScopedManager(organizer='order__event__organizer')
profiles = ScopedManager(organizer='customer__organizer')
@@ -3344,6 +3354,7 @@ class InvoiceAddress(models.Model):
self.internal_reference,
(_('Beneficiary') + ': ' + self.beneficiary) if self.beneficiary else '',
]
parts += [f'{k}: {v}' for k, v in self.describe_transmission()]
return '\n'.join([str(p).strip() for p in parts if p and str(p).strip()])
@property
@@ -3398,9 +3409,28 @@ class InvoiceAddress(models.Model):
'custom_field': self.custom_field,
'internal_reference': self.internal_reference,
'beneficiary': self.beneficiary,
'transmission_type': self.transmission_type,
**self.transmission_info,
})
return d
def describe_transmission(self):
from pretix.base.invoicing.transmission import transmission_types
data = []
t, __ = transmission_types.get(identifier=self.transmission_type)
data.append((_("Transmission type"), t.public_name))
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
for k, f in t.invoice_address_form_fields.items():
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 cachedticket_name(instance, filename: str) -> str:
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
+165 -3
View File
@@ -51,11 +51,16 @@ from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers,
)
from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
)
from pretix.base.signals import invoice_line_text, periodic_task
from pretix.celery_app import app
from pretix.helpers.database import OF_SELF, rolledback_transaction
@@ -71,12 +76,13 @@ def _location_oneliner(loc):
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
invoice.locale = invoice.event.settings.get('invoice_language', invoice.event.settings.locale)
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
if invoice.locale == '__user__':
invoice.locale = invoice.order.locale or invoice.event.settings.locale
lp = invoice.order.payments.last()
with language(invoice.locale, invoice.event.settings.region):
with (language(invoice.locale, invoice.event.settings.region)):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
@@ -127,6 +133,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.internal_reference = ia.internal_reference
invoice.custom_field = ia.custom_field
invoice.invoice_to_company = ia.company
invoice.invoice_to_is_business = ia.is_business
invoice.invoice_to_name = ia.name
invoice.invoice_to_street = ia.street
invoice.invoice_to_zipcode = ia.zipcode
@@ -134,6 +141,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.invoice_to_country = ia.country
invoice.invoice_to_state = ia.state
invoice.invoice_to_beneficiary = ia.beneficiary
invoice.invoice_to_transmission_info = ia.transmission_info or {}
invoice.transmission_type = ia.transmission_type
if ia.vat_id:
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
@@ -356,7 +365,9 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation.payment_provider_stamp = ''
cancellation.file = None
cancellation.sent_to_organizer = None
cancellation.sent_to_customer = None
cancellation.transmission_provider = None
cancellation.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
cancellation.transmission_date = None
with language(invoice.locale, invoice.event.settings.region):
cancellation.invoice_from = invoice.event.settings.get('invoice_address_from')
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -512,6 +523,36 @@ def build_preview_invoice_pdf(event):
return event.invoice_renderer.generate(invoice)
def order_invoice_transmission_separately(order):
try:
info = order.invoice_address.transmission_info or {}
return (
order.invoice_address.transmission_type != "email" or
(
info.get("transmission_email_address") and
order.email != info["transmission_email_address"]
)
)
except InvoiceAddress.DoesNotExist:
return False
def invoice_transmission_separately(invoice):
if not invoice:
return False
try:
info = invoice.invoice_to_transmission_info or {}
return (
invoice.transmission_type != "email" or
(
info.get("transmission_email_address") and
invoice.order.email != info["transmission_email_address"]
)
)
except InvoiceAddress.DoesNotExist:
return False
@receiver(signal=periodic_task)
@scopes_disabled()
def send_invoices_to_organizer(sender, **kwargs):
@@ -551,3 +592,124 @@ def send_invoices_to_organizer(sender, **kwargs):
else:
i.sent_to_organizer = False
i.save(update_fields=['sent_to_organizer'])
@receiver(signal=periodic_task)
@scopes_disabled()
def retry_stuck_invoices(sender, **kwargs):
with transaction.atomic():
qs = Invoice.objects.filter(
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
transmission_date__lte=now() - timedelta(hours=24),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
batch_size = 5000
for invoice in qs[:batch_size]:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, True))
@receiver(signal=periodic_task)
@scopes_disabled()
def send_pending_invoices(sender, **kwargs):
with transaction.atomic():
# Transmit all invoices that have not been transmitted by another process if the provider enforces
# transmission
types = [
tt.identifier for tt in get_transmission_types()
if tt.enforce_transmission
]
qs = Invoice.objects.filter(
transmission_type__in=types,
transmission_status=Invoice.TRANSMISSION_STATUS_PENDING,
created__lte=now() - timedelta(minutes=15),
).select_for_update(
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
)
batch_size = 5000
for invoice in qs[:batch_size]:
transmit_invoice.apply_async(args=(invoice.event_id, invoice.pk, False))
@app.task(base=TransactionAwareProfiledEventTask)
def transmit_invoice(sender, invoice_id, allow_retransmission=True, **kwargs):
with transaction.atomic(durable='tests.testdummy' not in settings.INSTALLED_APPS):
# We need durable=True for transactional correctness, but can't have it during tests
invoice = Invoice.objects.select_for_update(of=OF_SELF).get(pk=invoice_id)
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
logger.info(f"Did not transmit invoice {invoice.pk} due to being in inflight state.")
return
if invoice.transmission_status != Invoice.TRANSMISSION_STATUS_PENDING and not allow_retransmission:
logger.info(f"Did not transmit invoice {invoice.pk} due to status being {invoice.transmission_status}.")
return
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_INFLIGHT
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
providers = sorted([
provider
for provider, __ in transmission_providers.filter(type=invoice.transmission_type, active_in=sender)
], key=lambda p: (-p.priority, p.identifier))
provider = None
for p in providers:
if p.is_available(sender, invoice.invoice_to_country, invoice.invoice_to_is_business):
provider = p
break
if not provider:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "no_provider",
},
}
)
return
if invoice.order.testmode and not provider.testmode_supported:
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_TESTMODE_IGNORED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.testmode_ignored",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
}
)
return
try:
provider.transmit(invoice)
except Exception as e:
logger.exception(f"Transmission of invoice {invoice.pk} failed with exception.")
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
invoice.order.log_action(
"pretix.event.order.invoice.sending_failed",
data={
"full_invoice_no": invoice.full_invoice_no,
"transmission_provider": None,
"transmission_type": invoice.transmission_type,
"data": {
"reason": "exception",
"exception": str(e),
},
}
)
+49 -2
View File
@@ -658,8 +658,55 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
raise SendMailException('Failed to send an email to {}.'.format(to))
else:
for i in invoices_sent:
i.sent_to_customer = now()
i.save(update_fields=['sent_to_customer'])
if i.transmission_type == "email":
# Mark invoice as sent when it was sent to the requested address *either* at the time of invoice
# creation *or* as of right now.
expected_recipients = [
(i.invoice_to_transmission_info or {}).get("transmission_email_address") or i.order.email,
]
try:
expected_recipients.append(order.invoice_address.transmission_info.get("transmission_email_address") or i.order.email)
except InvoiceAddress.DoesNotExist:
pass
if not any(t in expected_recipients for t in to):
continue
if i.transmission_status != Invoice.TRANSMISSION_STATUS_COMPLETED:
i.transmission_date = now()
i.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
i.transmission_provider = "email_pdf"
i.transmission_info = {
"sent": [
{
"recipients": to,
"datetime": now().isoformat(),
}
]
}
i.save(update_fields=[
"transmission_date", "transmission_provider", "transmission_status",
"transmission_info"
])
elif i.transmission_provider == "email_pdf":
i.transmission_info["sent"].append(
{
"recipients": to,
"datetime": now().isoformat(),
}
)
i.save(update_fields=[
"transmission_info"
])
i.order.log_action(
"pretix.event.order.invoice.sent",
data={
"full_invoice_no": i.full_invoice_no,
"transmission_provider": "email_pdf",
"transmission_type": "email",
"data": {
"recipients": [to],
},
}
)
def mail_send(*args, **kwargs):
+57 -11
View File
@@ -84,6 +84,8 @@ from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, order_invoice_transmission_separately,
transmit_invoice,
)
from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
@@ -389,13 +391,19 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order_approved.send(order.event, order=order)
invoice = order.invoices.last() # Might be generated by plugin already
transmit_invoice_task = order_invoice_transmission_separately(order)
transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
invoice = generate_invoice(
order,
trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email
# send_mail will trigger PDF generation later
trigger_pdf=not transmit_invoice_mail
)
# send_mail will trigger PDF generation later
if transmit_invoice_task:
transmit_invoice.apply_async(args=(order.event_id, invoice.pk, False))
if send_mail:
with language(order.locale, order.event.settings.region):
@@ -423,7 +431,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
order.total == Decimal('0.00') or
order.valid_if_pending
),
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
invoices=[invoice] if invoice and transmit_invoice_mail else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
@@ -619,6 +627,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.create_transactions()
transmit_invoices_task = [i for i in invoices if invoice_transmission_separately(i)]
transmit_invoices_mail = [i for i in invoices if i not in transmit_invoices_task and order.event.settings.invoice_email_attachment]
for i in transmit_invoices_task:
transmit_invoice.apply_async(args=(order.event_id, i.pk, False))
if send_mail:
with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_canceled
@@ -628,7 +641,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user,
invoices=invoices if order.event.settings.invoice_email_attachment else []
invoices=transmit_invoices_mail,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
@@ -1085,11 +1098,12 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
def _order_placed_email(event: Event, order: Order, email_template, subject_template,
log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments)
try:
order.send_mail(
subject_template, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
invoices=[invoice] if invoice else [],
attach_tickets=True,
attach_ical=event.settings.mail_attach_ical and (
not event.settings.mail_attach_ical_paid_only or
@@ -1278,6 +1292,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
not order.require_approval
)
transmit_invoice_task = order_invoice_transmission_separately(order)
transmit_invoice_mail = not transmit_invoice_task and order.event.settings.invoice_email_attachment and order.email
invoice = order.invoices.last() # Might be generated by plugin already
if not invoice and invoice_qualified(order):
invoice_required = (
@@ -1291,9 +1308,11 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if invoice_required:
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
# send_mail will trigger PDF generation later
trigger_pdf=not transmit_invoice_mail
)
# send_mail will trigger PDF generation later
if transmit_invoice_task:
transmit_invoice.apply_async(args=(event.pk, invoice.pk, False))
if order.email:
if order.require_approval:
@@ -1320,8 +1339,16 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
is_free=free_order_flow)
_order_placed_email(
event,
order,
email_template,
subject_template,
log_entry,
invoice if transmit_invoice_mail else None,
payment_objs,
is_free=free_order_flow
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
@@ -2924,17 +2951,36 @@ class OrderChangeManager:
if self.split_order:
self.split_order.create_transactions()
transmit_invoices_task = [i for i in self._invoices if invoice_transmission_separately(i)]
transmit_invoices_mail = [
i for i in self._invoices
if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email
]
if self.split_order:
split_invoices = list(self.split_order.invoices.all())
transmit_invoices_task += [
i for i in split_invoices if invoice_transmission_separately(i)
]
split_transmit_invoices_mail = [
i for i in split_invoices
if i not in transmit_invoices_task and self.event.settings.invoice_email_attachment and self.order.email
]
if self.notify:
notify_user_changed_order(
self.order, self.user, self.auth,
self._invoices if self.event.settings.invoice_email_attachment else []
transmit_invoices_mail,
)
if self.split_order:
notify_user_changed_order(
self.split_order, self.user, self.auth,
list(self.split_order.invoices.all()) if self.event.settings.invoice_email_attachment else []
split_transmit_invoices_mail,
)
for i in transmit_invoices_task:
transmit_invoice.apply_async(args=(self.event.pk, i.pk, False))
order_changed.send(self.order.event, order=self.order)
def _clear_tickets_cache(self):
+14
View File
@@ -2695,6 +2695,20 @@ You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team""")) # noqa: W291
},
'mail_subject_order_invoice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}")),
},
'mail_text_order_invoice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
please find attached a new invoice for order {code} for {event}. This order has been placed by {order_email}.
Best regards,
Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {
+6 -2
View File
@@ -524,8 +524,11 @@ class InvoiceAddressShredder(BaseDataShredder):
d = le.parsed_data
if 'invoice_data' in d and not isinstance(d['invoice_data'], bool):
for field in d['invoice_data']:
if d['invoice_data'][field]:
d['invoice_data'][field] = ''
if d['invoice_data'][field] and field != "transmission_type":
if field == "transmission_info":
d['invoice_data'][field] = {"_shredded": True}
else:
d['invoice_data'][field] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
@@ -600,6 +603,7 @@ class InvoiceShredder(BaseDataShredder):
i.additional_text = ""
i.invoice_to = ""
i.payment_provider_text = ""
i.transmission_info = {"_shredded": True}
i.save()
i.lines.update(description="")
+78 -9
View File
@@ -21,38 +21,107 @@
#
import pycountry
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import pgettext
from django_countries.fields import Country
from django_scopes import scope
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
)
def states(request):
cc = request.GET.get("country", "DE")
def _info(cc):
info = {
'street': {'required': True},
'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'street': {'required': 'if_any'},
'zipcode': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
'city': {'required': 'if_any' if cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED else False},
'state': {
'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS,
'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False,
'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')),
},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return JsonResponse({'data': [], **info, })
return {'data': [], **info}
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return JsonResponse({
return {
'data': [
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
})
}
def address_form(request):
cc = request.GET.get("country", "DE")
info = _info(cc)
if request.GET.get("invoice") == "true":
# Do not consider live=True, as this does not expose sensitive information and we also want it accessible
# from e.g. the backend when the event is not yet life.
organizer = get_object_or_404(Organizer, slug=request.GET.get("organizer"))
with (scope(organizer=organizer)):
event = get_object_or_404(organizer.events, slug=request.GET.get("event"))
country = Country(cc)
is_business = request.GET.get("is_business") == "business"
selected_transmission_type = request.GET.get("transmission_type")
transmission_type_required = request.GET.get("transmission_type_required") == "true"
info["transmission_types"] = []
for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier}
if t.exclusive:
info["transmission_types"] = [result]
break
else:
info["transmission_types"].append(result)
info["transmission_type"] = {
# Hide transmission type if email is the only type since that's basically the backwards-compatible
# option
"visible": [t["code"] for t in info["transmission_types"]] != ["email"],
}
if selected_transmission_type not in [t["code"] for t in info["transmission_types"]]:
if transmission_type_required:
# The previously selected transmission type is no longer selectable, e.g. because
# of a country change. To avoid a second roundtrip to this endpoint, let's show
# the fields as if the first remaining option were selected (which is what the client
# side will now do).
selected_transmission_type = info["transmission_types"][0]["code"]
else:
selected_transmission_type = "-"
for transmission_type in get_transmission_types():
required = transmission_type.invoice_address_form_fields_required(
country=country,
is_business=is_business
)
visible = transmission_type.invoice_address_form_fields_visible(
country=country,
is_business=is_business
)
if transmission_type.identifier == selected_transmission_type:
for k, v in info.items():
if k in required:
v["required"] = True
if k in visible:
v["visible"] = True
for k, f in transmission_type.invoice_address_form_fields.items():
info[k] = {
"visible": transmission_type.identifier == selected_transmission_type and k in visible,
"required": transmission_type.identifier == selected_transmission_type and k in required
}
return JsonResponse(info)
+16
View File
@@ -1199,6 +1199,20 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
required=False,
widget=I18nMarkdownTextarea,
)
mail_subject_order_invoice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_text_order_invoice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
"than the order confirmation."),
)
mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"),
required=False,
@@ -1350,6 +1364,8 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_text_order_payment_failed': ['event', 'order'],
'mail_subject_order_payment_failed': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_order_invoice': ['event', 'order', 'invoice'],
'mail_subject_order_invoice': ['event', 'order', 'invoice'],
'mail_text_download_reminder': ['event', 'order'],
'mail_subject_download_reminder': ['event', 'order'],
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
+6
View File
@@ -524,6 +524,11 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.invoice.generated': _('The invoice has been generated.'),
'pretix.event.order.invoice.regenerated': _('The invoice has been regenerated.'),
'pretix.event.order.invoice.reissued': _('The invoice has been reissued.'),
'pretix.event.order.invoice.sent': _('The invoice {full_invoice_no} has been sent.'),
'pretix.event.order.invoice.sending_failed': _('The transmission of invoice {full_invoice_no} has failed.'),
'pretix.event.order.invoice.testmode_ignored': _('Invoice {full_invoice_no} has not been transmitted because '
'no transmission provider supports test mode invoices.'),
'pretix.event.order.invoice.retransmitted': _('The invoice {full_invoice_no} has been scheduled for retransmission.'),
'pretix.event.order.comment': _('The order\'s internal comment has been updated.'),
'pretix.event.order.custom_followup_at': _('The order\'s follow-up date has been updated.'),
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
@@ -536,6 +541,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.email.error': _('Sending of an email has failed.'),
'pretix.event.order.email.attachments.skipped': _('The email has been sent without attached tickets since they '
'would have been too large to be likely to arrive.'),
'pretix.event.order.email.invoice': _('An invoice email has been sent.'),
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
'pretix.event.order.position.email.custom_sent': _('A custom email has been sent to an attendee.'),
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load getitem %}
{% block inside %}
<h1>{% trans "Invoice settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
@@ -59,6 +60,99 @@
{% bootstrap_field form.invoice_renderer_highlight_order_code layout="control" %}
{% bootstrap_field form.invoice_eu_currencies layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Invoice transmission" %}</legend>
<p>
{% blocktrans trimmed %}
pretix can transmit invoices using different transmission methods. Different transmission methods
might be required depending on country and industry. By default, sending invoices as PDF files
via email is always available. Other types of transmission can be added by plugins.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
Whether a transmission method listed here is actually selectable for customers may depend on
the country of the customer or whether the customer is entering a business address.
{% endblocktrans %}
</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>{% trans "Transmission method" %}</th>
<th>{% trans "Status" %}</th>
<th></th>
</tr>
</thead>
{% for t in transmission_types %}
{% if transmission_providers|getitem:t.identifier %}
<tbody>
<tr>
<th scope="colgroup" class="text-muted">
{{ t.verbose_name }}
</th>
<th>
{% if ready|getitem:t.identifier %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% 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>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Unavailable" %}
</span>
{% endif %}
</th>
<th></th>
</tr>
</tbody>
<tbody>
{% for p, is_ready, settings_url in transmission_providers|getitem:t.identifier %}
<tr>
<td>
{{ p.verbose_name }}
</td>
<td>
{% if is_ready %}
<span class="text-success">
<span class="fa fa-check fa-fw"></span>
{% trans "Available" %}
</span>
{% else %}
<span class="text-muted">
<span class="fa fa-ban fa-fw"></span>
{% trans "Not configured" %}
</span>
{% endif %}
</td>
<td class="text-right">
{% if settings_url %}
<a href="{{ settings_url }}" class="btn btn-default">
<span class="fa fa-cog" aria-hidden="true"></span>
{% trans "Settings" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
{% endfor %}
</table>
</div>
<p>
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
<a href="{{ plugin_settings_url }}" class="btn btn-default">
<i class="fa fa-plus"></i> {% trans "Enable additional invoice transmission plugins" %}
</a>
</p>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-default btn-lg" name="preview" value="preview" formtarget="_blank">
@@ -117,6 +117,9 @@
{% blocktrans asvar title_order_custom_mail %}Order custom mail{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="custom_mail" title=title_order_custom_mail items="mail_text_order_custom_mail" %}
{% blocktrans asvar title_order_custom_mail %}Invoice{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_invoice" title=title_order_custom_mail items="mail_subject_order_invoice,mail_text_order_invoice" %}
{% blocktrans asvar title_download_tickets_reminder %}Reminder to download tickets{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
@@ -27,7 +27,8 @@
{% endif %}</strong>
</h4>
</summary>
<div id="invoice">
<div id="invoice"
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ request.event.organizer.slug|urlencode }}&event={{ request.event.slug|urlencode }}">
<div class="panel-body">
{% bootstrap_form invoice_form layout="horizontal" %}
</div>
@@ -271,24 +271,70 @@
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}" target="_blank">
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
{{ i.number }}</a>
({{ i.date|date:"SHORT_DATE_FORMAT" }})
{% if i.sent_to_customer.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
({{ i.date|date:"SHORT_DATE_FORMAT" }}, {{ i.transmission_type_instance.verbose_name }})
{% if i.transmission_status == "unknown" %}
{# Legacy invoice, before the introduction of transmission status #}
{% if i.transmission_date.year == 1970 %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "We don't know if this invoice was emailed to the customer since it was created before our system tracked this information" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_date %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% elif i.transmission_status == "pending" %}
{% if i.transmission_type_instance.enforce_transmission %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is scheduled to be transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-clock-o text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa fa-envelope fa-background text-muted" data-toggle="tooltip" title="{% trans "Invoice is not yet transmitted" %}"></span>
{% endif %}
{% elif i.transmission_status == "inflight" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice is currently in transmission" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-question fa-stack-1x fa-stack-shifted"></span>
<span class="fa fa-send text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.sent_to_customer %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was emailed to customer" %}">
{% elif i.transmission_status == "testmode_ignored" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice not transmitted in test mode" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-ban text-warning fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "failed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice transmission failed" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-exclamation-circle text-danger fa-stack-1x fa-stack-shifted"></span>
</span>
{% elif i.transmission_status == "completed" %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice has been transmitted" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
<span class="fa fa-check text-success fa-stack-1x fa-stack-shifted"></span>
</span>
{% else %}
<span class="fa-stack fa-stack-small" data-toggle="tooltip" title="{% trans "Invoice was not yet emailed to customer" %}">
<span class="fa fa-background fa-envelope text-muted fa-stack-1x"></span>
</span>
{% endif %}
{% if i.transmission_status != "inflight" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.retransmitinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
<button class="btn btn-default btn-xs" data-toggle="tooltip"
title="{{ i.transmission_type_instance.verbose_name }}">
{% if i.transmission_status == "pending" %}
{% trans "Transmit" %}
{% else %}
{% trans "Retransmit" %}
{% endif %}
</button>
</form>
{% endif %}
{% if not i.canceled %}
{% if request.event.settings.invoice_regenerate_allowed %}
{% if i.regenerate_allowed %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.regeninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
{% csrf_token %}
@@ -320,12 +366,6 @@
<br/>
{% endif %}
{% endfor %}
{% if invoices_send_link %}
<br/>
<a class="btn btn-default btn-xs" href="{{ invoices_send_link }}">
{% trans "Email invoices" %}
</a>
{% endif %}
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/>
<form class="form-inline helper-display-inline" method="post"
@@ -989,8 +1029,14 @@
<dt>{{ request.event.settings.invoice_address_custom_field }}</dt>
<dd>{{ order.invoice_address.custom_field }}</dd>
{% endif %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if order.invoice_address.internal_reference %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% endif %}
{% for k, v in order.invoice_address.describe_transmission %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
</dl>
</div>
</div>
+2
View File
@@ -383,6 +383,8 @@ urlpatterns = [
name='event.order.geninvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/regenerate$', orders.OrderInvoiceRegenerate.as_view(),
name='event.order.regeninvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/retransmit$', orders.OrderInvoiceRetransmit.as_view(),
name='event.order.retransmitinvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/reissue$', orders.OrderInvoiceReissue.as_view(),
name='event.order.reissueinvoice'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<position>\d+)/(?P<output>[^/]+)/$',
+20 -1
View File
@@ -37,7 +37,7 @@ import json
import logging
import operator
import re
from collections import OrderedDict
from collections import OrderedDict, defaultdict
from decimal import Decimal
from io import BytesIO
from itertools import groupby
@@ -76,6 +76,9 @@ from i18nfield.utils import I18nJSONEncoder
from pretix.base.email import get_available_placeholders
from pretix.base.forms import PlaceholderValidator
from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers,
)
from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher
from pretix.base.models.event import EventMetaValue
from pretix.base.services import tickets
@@ -651,6 +654,22 @@ class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
template_name = 'pretixcontrol/event/invoicing.html'
permission = 'can_change_event_settings'
def get_context_data(self, **kwargs):
types = get_transmission_types()
providers = defaultdict(list)
ready = defaultdict(lambda: False)
for p, __ in transmission_providers.filter(active_in=self.request.event):
is_ready_result = p.is_ready(self.request.event)
providers[p.type].append((p, is_ready_result, p.settings_url(self.request.event)))
ready[p.type] = ready[p.type] or is_ready_result
for k, v in providers.items():
v.sort(key=lambda p: (-p[0].priority, p[0].identifier))
return super().get_context_data(
transmission_providers=providers,
transmission_types=types,
ready=ready,
)
def get_success_url(self) -> str:
if 'preview' in self.request.POST:
return reverse('control:event.settings.invoice.preview', kwargs={
+35 -23
View File
@@ -66,7 +66,7 @@ from django.utils.html import conditional_escape, escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.safestring import mark_safe
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext, gettext_lazy as _, ngettext
from django.utils.translation import gettext, gettext_lazy as _
from django.views.generic import (
DetailView, FormView, ListView, TemplateView, View,
)
@@ -93,7 +93,7 @@ from pretix.base.services.cancelevent import cancel_event
from pretix.base.services.export import export, scheduled_event_export
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_pdf_task,
invoice_qualified, regenerate_invoice,
invoice_qualified, regenerate_invoice, transmit_invoice,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import (
@@ -551,27 +551,6 @@ class OrderDetail(OrderView):
ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum
unsent_invoices = [ii.pk for ii in ctx['invoices'] if not ii.sent_to_customer]
if unsent_invoices:
with language(self.order.locale):
ctx['invoices_send_link'] = reverse('control:event.order.sendmail', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'code': self.order.code
}) + '?' + urlencode({
'subject': ngettext('Your invoice', 'Your invoices', len(unsent_invoices)),
'message': ngettext(
'Hello,\n\nplease find your invoice attached to this email.\n\n'
'Your {event} team',
'Hello,\n\nplease find your invoices attached to this email.\n\n'
'Your {event} team',
len(unsent_invoices)
).format(
event="{event}",
),
'attach_invoices': unsent_invoices
}, doseq=True)
return ctx
@cached_property
@@ -1681,6 +1660,8 @@ class OrderInvoiceRegenerate(OrderView):
else:
if not inv.event.settings.invoice_regenerate_allowed:
messages.error(self.request, _('Invoices may not be changed after they are created.'))
elif not inv.regenerate_allowed:
messages.error(self.request, _('Invoices may not be changed after they are transmitted.'))
if inv.canceled:
messages.error(self.request, _('The invoice has already been canceled.'))
elif inv.sent_to_organizer:
@@ -1701,6 +1682,37 @@ class OrderInvoiceRegenerate(OrderView):
return HttpResponseNotAllowed(['POST'])
class OrderInvoiceRetransmit(OrderView):
permission = 'can_change_orders'
def post(self, *args, **kwargs):
with transaction.atomic(durable=True):
try:
invoice = self.order.invoices.select_for_update(of=OF_SELF).get(pk=kwargs.get("id"))
except Invoice.DoesNotExist:
messages.error(self.request, _('Unknown invoice.'))
return redirect(self.get_order_url())
if invoice.transmission_status == Invoice.TRANSMISSION_STATUS_INFLIGHT:
messages.error(self.request, _('The invoice is currently being transmitted. You can start a new attempt after '
'the current one has been completed.'))
return redirect(self.get_order_url())
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_PENDING
invoice.transmission_date = now()
invoice.save(update_fields=["transmission_status", "transmission_date"])
messages.success(self.request, _('The invoice has been scheduled for retransmission.'))
self.order.log_action('pretix.event.order.invoice.retransmitted', user=self.request.user, data={
'invoice': invoice.pk,
'full_invoice_no': invoice.full_invoice_no,
})
transmit_invoice.apply_async(args=(self.request.event.pk, invoice.pk, True))
return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA
return HttpResponseNotAllowed(['POST'])
class OrderInvoiceReissue(OrderView):
permission = 'can_change_orders'
+4 -5
View File
@@ -29915,8 +29915,8 @@ msgstr "Rechnung {invoice_number}"
msgid ""
"Hello,\n"
"\n"
"you receive this message because an order for {event} was placed by "
"{order_email} and we have been asked to forward the invoice to you.\n"
"please find attached a new invoice for order {code} for {event}. "
"This order has been placed by {order_email}.\n"
"\n"
"Best regards, \n"
"\n"
@@ -29924,9 +29924,8 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"Sie erhalten diese Nachricht weil eine Bestellung für {event} von "
"{order_email} aufgegeben wurde und wir darum gebeten wurden, Ihnen die "
"Rechnung weiterzuleiten.\n"
"anbei erhalten Sie eine neue Rechnung für Bestellung {code} für {event}. "
"Die Bestellung wurde von {order_email} aufgegeben.\n"
"\n"
"Viele Grüße, \n"
"Das {event} Team"
@@ -29869,8 +29869,8 @@ msgstr "Rechnung {invoice_number}"
msgid ""
"Hello,\n"
"\n"
"you receive this message because an order for {event} was placed by "
"{order_email} and we have been asked to forward the invoice to you.\n"
"please find attached a new invoice for order {code} for {event}. "
"This order has been placed by {order_email}.\n"
"\n"
"Best regards, \n"
"\n"
@@ -29878,9 +29878,8 @@ msgid ""
msgstr ""
"Hallo,\n"
"\n"
"du erhältst diese Nachricht weil eine Bestellung für {event} von "
"{order_email} aufgegeben wurde und wir darum gebeten wurden, dir die "
"Rechnung weiterzuleiten.\n"
"anbei erhältst du eine neue Rechnung für Bestellung {code} für {event}. "
"Die Bestellung wurde von {order_email} aufgegeben.\n"
"\n"
"Viele Grüße, \n"
"Das {event} Team"
+4 -171
View File
@@ -40,7 +40,6 @@ from django import forms
from django.core.exceptions import ValidationError
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea
from i18nfield.forms import I18nTextInput
@@ -49,15 +48,9 @@ from localflavor.generic.forms import BICFormField, IBANFormField
from localflavor.generic.validators import IBANValidator
from text_unidecode import unidecode
from pretix.base.email import get_available_placeholders, get_email_context
from pretix.base.forms import PlaceholderValidator
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Order, OrderPayment, OrderRefund
from pretix.base.payment import BasePaymentProvider
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.base.templatetags.money import money_filter
from pretix.helpers.format import format_map
from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat
from pretix.presale.views.cart import cart_session
@@ -208,48 +201,6 @@ class BankTransfer(BasePaymentProvider):
@property
def settings_form_fields(self):
placeholders = get_available_placeholders(self.event, ['event', 'order', 'invoice'])
phs_ht = format_placeholders_help_text(placeholders, self.event)
phs = ['{%s}' % p for p in placeholders]
more_fields = OrderedDict([
('invoice_email',
forms.BooleanField(
label=_('Allow users to enter an additional email address that the invoice will be sent to.'),
help_text=_(
'This requires that the invoice creation settings allow the invoice to be created right after '
'the payment method was chosen. Only the invoice will be sent to this email address, subsequent '
'invoice corrections will not be sent automatically. Only the invoice will be sent, no additional '
'information.'
),
required=False,
)),
('invoice_email_subject',
I18nFormField(
label=_('Invoice email subject'),
widget=I18nTextInput,
widget_kwargs={'attrs': {
'data-display-dependency': '#id_payment_banktransfer_invoice_email',
'data-required-if': '#id_payment_banktransfer_invoice_email',
}},
validators=[PlaceholderValidator(phs)],
help_text=phs_ht,
required=False
)),
('invoice_email_text',
I18nFormField(
label=_('Invoice email text'),
widget=I18nTextarea,
widget_kwargs={'attrs': {
'rows': '8',
'data-display-dependency': '#id_payment_banktransfer_invoice_email',
'data-required-if': '#id_payment_banktransfer_invoice_email',
}},
validators=[PlaceholderValidator(phs)],
help_text=phs_ht,
required=False
)),
])
more_fields_first = OrderedDict([
('_restricted_business',
forms.BooleanField(
@@ -263,8 +214,7 @@ class BankTransfer(BasePaymentProvider):
d = OrderedDict(
list(super().settings_form_fields.items()) +
list(more_fields_first.items()) +
list(BankTransfer.form_fields().items()) +
list(more_fields.items())
list(BankTransfer.form_fields().items())
)
d.move_to_end('invoice_immediately', last=False)
d.move_to_end('bank_details', last=False)
@@ -292,16 +242,6 @@ class BankTransfer(BasePaymentProvider):
{'payment_banktransfer_bank_details': _('Please enter your bank account details.')})
return cleaned_data
@cached_property
def _invoice_email_asked(self):
return (
self.settings.get('invoice_email', as_type=bool) and
(self.event.settings.invoice_generate == 'True' or (
self.event.settings.invoice_generate == 'paid' and
self.settings.get('invoice_immediately', as_type=bool)
))
)
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
def get_invoice_address():
if not hasattr(request, '_checkout_flow_invoice_address'):
@@ -324,35 +264,10 @@ class BankTransfer(BasePaymentProvider):
return super().is_allowed(request, total)
@property
def payment_form_fields(self) -> dict:
if self._invoice_email_asked:
return {
'send_invoice': forms.BooleanField(
label=_('Please additionally send my invoice directly to our accounting department'),
required=False,
),
'send_invoice_to': forms.EmailField(
label=_('Invoice recipient email'),
required=False,
help_text=_('The invoice recipient will receive an email which includes the invoice and your email '
'address so they know who placed this order.'),
widget=forms.EmailInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer-send_invoice',
}
)
),
}
else:
return {}
def payment_form_render(self, request, total=None, order=None) -> str:
template = get_template('pretixplugins/banktransfer/checkout_payment_form.html')
form = self.payment_form(request)
ctx = {
'request': request,
'form': form,
'event': self.event,
'settings': self.settings,
'code': self._code(order) if order else None,
@@ -361,97 +276,16 @@ class BankTransfer(BasePaymentProvider):
return template.render(ctx)
def checkout_prepare(self, request, total):
form = self.payment_form(request)
if form.is_valid():
for k, v in form.cleaned_data.items():
request.session['payment_%s_%s' % (self.identifier, k)] = v
return True
else:
return False
def send_invoice_to_alternate_email(self, order, invoice, email):
"""
Sends an email to the alternate invoice address.
"""
with language(order.locale, self.event.settings.region):
context = get_email_context(event=self.event,
order=order,
invoice=invoice,
event_or_subevent=self.event,
invoice_address=getattr(order, 'invoice_address', None) or InvoiceAddress())
template = self.settings.get('invoice_email_text', as_type=LazyI18nString)
subject = self.settings.get('invoice_email_subject', as_type=LazyI18nString)
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
email,
subject,
template,
context=context,
event=self.event,
locale=order.locale,
order=order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
order.log_action(
'pretix.plugins.banktransfer.order.email.invoice',
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': email,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
}
)
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
send_invoice = (
self._invoice_email_asked and
request and
request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice')) and
request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to'))
)
if send_invoice:
recipient = request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to'))
payment.info_data = {
'send_invoice_to': recipient,
}
payment.save(update_fields=['info'])
i = payment.order.invoices.filter(is_cancellation=False).last()
if i:
self.send_invoice_to_alternate_email(payment.order, i, recipient)
if request:
request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice'), None)
request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice_to'), None)
return True
def payment_prepare(self, request: HttpRequest, payment: OrderPayment):
return self.checkout_prepare(request, payment.amount)
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request, order=None):
template = get_template('pretixplugins/banktransfer/checkout_confirm.html')
ctx = {
'request': request,
'event': self.event,
'settings': self.settings,
'code': self._code(order) if order else None,
'details': self.settings.get('bank_details', as_type=LazyI18nString),
}
return template.render(ctx)
return self.payment_form_render(request, order=order)
def order_pending_mail_render(self, order, payment) -> str:
t = gettext("Please transfer the full amount to the following bank account:")
@@ -537,7 +371,6 @@ class BankTransfer(BasePaymentProvider):
'pending_description': self.settings.get('pending_description', as_type=LazyI18nString),
'details': self.settings.get('bank_details', as_type=LazyI18nString),
'has_invoices': payment.order.invoices.exists(),
'invoice_email_enabled': self.settings.get('invoice_email', as_type=bool),
}
ctx['any_barcodes'] = ctx['swiss_qrbill'] or ctx['eu_barcodes']
return template.render(ctx, request=request)
+2 -21
View File
@@ -22,8 +22,7 @@
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve, reverse
from django.utils.translation import gettext_lazy as _, gettext_noop
from i18nfield.strings import LazyI18nString
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import register_payment_providers
from pretix.control.signals import html_head, nav_event, nav_organizer
@@ -31,7 +30,6 @@ from pretix.control.signals import html_head, nav_event, nav_organizer
from ...base.logentrytypes import (
ClearDataShredderMixin, OrderLogEntryType, log_entry_types,
)
from ...base.settings import settings_hierarkey
from .payment import BankTransfer
@@ -122,23 +120,6 @@ def html_head_presale(sender, request=None, **kwargs):
@log_entry_types.new()
class BanktransferOrderEmailInvoiceLogEntryType(OrderLogEntryType, ClearDataShredderMixin):
# For backwards-compatibility only
action_type = 'pretix.plugins.banktransfer.order.email.invoice'
plain = _('The invoice was sent to the designated email address.')
settings_hierarkey.add_default(
'payment_banktransfer_invoice_email_subject',
default_type=LazyI18nString,
value=LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}"))
)
settings_hierarkey.add_default(
'payment_banktransfer_invoice_email_text',
default_type=LazyI18nString,
value=LazyI18nString.from_gettext(gettext_noop("""Hello,
you receive this message because an order for {event} was placed by {order_email} and we have been asked to forward the invoice to you.
Best regards,
Your {event} team""")) # noqa: W291
)
@@ -38,10 +38,3 @@
reference code.
{% endblocktrans %}</p>
{% endif %}
{% if request.session.payment_banktransfer_send_invoice and request.session.payment_banktransfer_send_invoice_to %}
<p>
{% blocktrans trimmed with recipient=request.session.payment_banktransfer_send_invoice_to %}
We will send a copy of your invoice directly to {{ recipient }}.
{% endblocktrans %}
</p>
{% endif %}
@@ -1,6 +1,5 @@
{% load i18n %}
{% load ibanformat %}
{% load bootstrap3 %}
{% if details or code %}
<p>{% blocktrans trimmed %}
@@ -38,8 +37,3 @@
reference code.
{% endblocktrans %}</p>
{% endif %}
{% if form.fields %}
<div class="col-md-12">
{% bootstrap_form form layout='inline' %}
</div>
{% endif %}
@@ -133,42 +133,4 @@ SCT
</div>
{% if swiss_qrbill %}
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
{% endif %}
{% if invoice_email_enabled and has_invoices %}
{% if payment_info.send_invoice_to %}
<p>
{% blocktrans trimmed with recipient=payment_info.send_invoice_to %}
At your request, we sent the invoice directly to {{ recipient }}.
{% endblocktrans %}
<a data-toggle="collapse" data-target="#payment_banktransfer_send_invoice_to" class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
{% trans "Send again or somewhere else" %}
</a>
</p>
{% endif %}
<form id="payment_banktransfer_send_invoice_to" method="post"
action="{% eventurl event "plugins:banktransfer:mail_invoice" order=order.code secret=order.secret %}"
{% if payment_info.send_invoice_to %}class="collapse"{% endif %}>
{% csrf_token %}
<p>
{% blocktrans trimmed %}
To send the invoice directly to your accounting department, please enter their email address:
{% endblocktrans %}
</p>
<div class="row">
<div class="col-md-9 col-xs-12">
<label for="mail_invoice_email" class="sr-only">{% trans "Invoice recipient email" %}:</label>
<input type="email" name="email" id="mail_invoice_email" class="form-control" value="" required
placeholder="{% trans "Email address" %}" />
</div>
<div class="col-md-3 col-xs-12">
<button class="btn btn-default btn-block">
<span class="fa fa-envelope-o" aria-hidden="true"></span>
{% trans "Send invoice via email" %}
</button>
</div>
</div>
</form>
<hr>
{% endif %}
{% endif %}
+1 -7
View File
@@ -19,19 +19,13 @@
# 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/>.
#
from django.urls import include, re_path
from django.urls import re_path
from pretix.api.urls import orga_router
from pretix.plugins.banktransfer.api import BankImportJobViewSet
from . import views
event_patterns = [
re_path(r'^banktransfer/', include([
re_path(r'^(?P<order>[^/][^w]+)/(?P<secret>[A-Za-z0-9]+)/mail-invoice/$', views.SendInvoiceMailView.as_view(), name='mail_invoice'),
])),
]
urlpatterns = [
re_path(r'^control/organizer/(?P<organizer>[^/]+)/banktransfer/import/',
views.OrganizerImportView.as_view(),
+1 -46
View File
@@ -44,18 +44,14 @@ from typing import Set
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db import transaction
from django.db.models import Count, Q, QuerySet
from django.http import FileResponse, Http404, JsonResponse
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import DetailView, FormView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from localflavor.generic.forms import BICFormField, IBANFormField
@@ -79,8 +75,6 @@ from pretix.plugins.banktransfer.refund_export import (
build_sepa_xml, get_refund_export_csv,
)
from pretix.plugins.banktransfer.tasks import process_banktransfers
from pretix.presale.views import EventViewMixin
from pretix.presale.views.order import OrderDetailMixin
logger = logging.getLogger('pretix.plugins.banktransfer')
@@ -892,42 +886,3 @@ class OrganizerSepaXMLExportView(OrganizerPermissionRequiredMixin, OrganizerDeta
organizer=self.request.organizer,
pk=self.kwargs.get('id')
)
@method_decorator(xframe_options_exempt, 'dispatch')
class SendInvoiceMailView(EventViewMixin, OrderDetailMixin, View):
def post(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
try:
validate_email(request.POST['email'])
except ValidationError:
messages.error(request, _('Please enter a valid email address.'))
return redirect(self.get_order_url())
last_payment = self.order.payments.last()
if (not last_payment
or last_payment.provider != BankTransfer.identifier
or last_payment.state != OrderPayment.PAYMENT_STATE_CREATED):
messages.error(request, _('No pending bank transfer payment found. Maybe the order has been paid already?'))
return redirect(self.get_order_url())
if not last_payment.payment_provider.settings.get('invoice_email', as_type=bool):
messages.error(request, _('Sending invoices via email is disabled by the event organizer.'))
return redirect(self.get_order_url())
last_invoice = self.order.invoices.last()
if not last_invoice:
messages.error(request, _('No invoice found, please request an invoice first.'))
return redirect(self.get_order_url())
provider = last_payment.payment_provider
provider.send_invoice_to_alternate_email(self.order, last_invoice, request.POST['email'])
last_payment.info_data = {
**last_payment.info_data,
'send_invoice_to': request.POST['email'],
}
last_payment.save(update_fields=['info'])
messages.success(request, _('Sending the latest invoice via email to {email}.').format(email=request.POST['email']))
return redirect(self.get_order_url())
+4 -2
View File
@@ -1130,7 +1130,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
'invoice' in self.request.GET or
# Checking for self.invoice_address.pk is not enough as when an invoice_address has been added and later edited to be empty, its not None.
# So check initial values as invoice_form can receive pre-filled values from invoice_address, widget-data or overwrites from plug-ins.
is_form_filled(self.invoice_form, ignore_keys=('is_business', 'country'))
is_form_filled(self.invoice_form, ignore_keys=('is_business', 'country', 'transmission_type'))
)
if self.cart_customer:
@@ -1144,6 +1144,7 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
"_state_for_address": a.state_for_address,
"_name": a.name,
"is_business": "business" if a.is_business else "individual",
**a.transmission_info,
}
if a.name_parts:
name_parts = a.name_parts
@@ -1165,7 +1166,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
for k in (
"company", "street", "zipcode", "city", "country", "state",
"state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary"
"state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary",
"transmission_type",
):
v = getattr(a, k) or ""
# always add all values of an address even when empty,
@@ -111,6 +111,10 @@
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ addr.internal_reference }}</dd>
{% endif %}
{% for k, v in addr.describe_transmission %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
</dl>
</div>
</div>
@@ -40,7 +40,9 @@
{% if addresses_data %}
{{ addresses_data|json_script:"addresses_json" }}
{% endif %}
<div id="invoice" class="profile-scope" data-profiles-id="addresses_json">
<div id="invoice" class="profile-scope"
data-profiles-id="addresses_json"
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ event.organizer.slug|urlencode }}&event={{ event.slug|urlencode }}">
<div class="panel-body">
{% if addresses_data %}
<div class="form-group profile-select-container js-do-not-copy-answers">
@@ -78,7 +80,7 @@
</h3>
</summary>
<div>
<div class="panel-body questions-form">
<div class="panel-body questions-form" data-address-information-url="{% url "js_helpers.address_form" %}">
{% if event.settings.attendee_data_explanation_text and pos.item.ask_attendee_data %}
{{ event.settings.attendee_data_explanation_text|rich_text }}
{% endif %}
@@ -335,6 +335,10 @@
<dt>{% trans "Internal Reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% endif %}
{% for k, v in order.invoice_address.describe_transmission %}
<dt>{{ k }}</dt>
<dd>{{ v }}</dd>
{% endfor %}
{% endif %}
</dl>
</div>
@@ -35,14 +35,15 @@
</strong>
</h4>
</summary>
<div id="invoice" class="panel-collapse">
<div id="invoice" class="panel-collapse"
data-address-information-url="{% url "js_helpers.address_form" %}?invoice=true&organizer={{ event.organizer.slug|urlencode }}&event={{ event.slug|urlencode }}">
<div class="panel-body">
{% if event.settings.invoice_address_explanation_text %}
<div>
{{ event.settings.invoice_address_explanation_text|rich_text }}
</div>
{% endif %}
{% bootstrap_form invoice_form layout="horizontal" %}
{% bootstrap_form invoice_form layout="checkout" %}
</div>
</div>
</details>
+117 -73
View File
@@ -1,83 +1,127 @@
$(function () {
"use strict";
$("select[data-country-information-url]").each(function () {
$("[data-address-information-url]").each(function () {
let xhr;
const dependency = $(this),
loader = $("<span class='fa fa-cog fa-spin'></span>").hide().prependTo(dependency.closest(".form-group").find("label")),
url = this.getAttribute('data-country-information-url'),
form = dependency.closest(".panel-body, form, .profile-scope"),
isRequired = dependency.closest(".form-group").is(".required"),
dependents = {
'city': form.find("input[name$=city]"),
'zipcode': form.find("input[name$=zipcode]"),
'street': form.find("textarea[name$=street]"),
'state': form.find("select[name$=state]"),
'vat_id': form.find("input[name$=vat_id]"),
},
update = function (ev) {
if (xhr) {
xhr.abort();
const form = $(this);
const dependencies = $(this).find("[data-trigger-address-info]");
const loader = $("<span class='fa fa-cog fa-spin'></span>").hide().prependTo(dependencies.closest(".form-group").find("label").first())
const baseUrl = this.getAttribute('data-address-information-url')
const isAnyRequired = dependencies.toArray().some(function (e) { return $(e).closest(".form-group").is(".required") });
const dependents = {
'city': form.find("input[name$=city]"),
'zipcode': form.find("input[name$=zipcode]"),
'street': form.find("textarea[name$=street]"),
'state': form.find("select[name$=state]"),
'vat_id': form.find("input[name$=vat_id]"),
};
form.find("select[name*=transmission_], textarea[name*=transmission_], input[name*=transmission_]").each(function () {
dependents[$(this).attr("name").split("-").pop()] = $(this)
})
const update = function (ev) {
if (xhr) {
xhr.abort();
}
dependents.state.prop("data-selected-value", dependents.state.val());
if (dependents.transmission_type) {
dependents.transmission_type.prop("data-selected-value", dependents.transmission_type.val());
}
for (var k in dependents) dependents[k].prop("disabled", true);
loader.show();
var url = new URL(baseUrl, location.href);
// Address depends on all annotated fields
form.find("[data-trigger-address-info]").each(function () {
// Remove prefix of the form to get actual field name
if (($(this).attr("type") === "radio" || $(this).attr("type") === "checkbox") && !$(this).prop("checked")) {
return
}
for (var k in dependents) dependents[k].prop("disabled", true);
loader.show();
xhr = $.getJSON(url + '?country=' + dependency.val(), function (data) {
var selected_value = dependents.state.prop("data-selected-value");
if (selected_value) dependents.state.prop("data-selected-value", "");
dependents.state.find("option:not([value=''])").remove();
if (data.data.length > 0) {
$.each(data.data, function (k, s) {
var o = $("<option>").attr("value", s.code).text(s.name);
if (selected_value == s.code) o.prop("selected", true);
dependents.state.append(o);
});
}
for(var k in dependents) {
const options = data[k],
dependent = dependents[k];
let visible = 'visible' in options ? options.visible : true;
if (dependent.is("[data-display-dependency]")) {
const dependency = $(dependent.attr("data-display-dependency"));
visible = visible && (
(dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val()
);
}
if ('label' in options) {
dependent.closest(".form-group").find(".control-label").text(options.label);
}
const required = 'required' in options && options.required && isRequired && visible;
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required);
const label = dependent.closest(".form-group").find("label");
const labelRequired = label.find(".label-required");
if (!required) {
labelRequired.remove();
} else if (!labelRequired.length) {
label.append('<i class="label-required">' + gettext('required') + '</i>')
}
}
for (var k in dependents) dependents[k].prop("disabled", false);
}).always(function() {
loader.hide();
}).fail(function(){
// In case of errors, show everything and require nothing, we can still handle errors in backend
for(var k in dependents) {
const dependent = dependents[k],
visible = true,
required = false;
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required);
dependent.closest(".form-group").find("label .label-required").remove();
}
url.searchParams.append($(this).attr("name").split("-").pop(), $(this).val());
})
if (dependents.transmission_type) {
url.searchParams.append("transmission_type_required", !dependents.transmission_type.find("option[value='-']").length);
}
xhr = $.getJSON(url, function (data) {
var selected_state = dependents.state.prop("data-selected-value");
if (selected_state) dependents.state.prop("data-selected-value", "");
dependents.state.find("option:not([value=''])").remove();
$.each(data.data, function (k, s) {
var o = $("<option>").attr("value", s.code).text(s.name);
if (selected_state === s.code) o.prop("selected", true);
dependents.state.append(o);
});
};
dependents.state.prop("data-selected-value", dependents.state.val());
if (dependents.transmission_type) {
var selected_transmission_type = dependents.transmission_type.prop("data-selected-value");
if (selected_transmission_type) dependents.transmission_type.prop("data-selected-value", "");
dependents.transmission_type.find("option:not([value='']):not([value='-'])").remove();
if (!data.transmission_type.visible) {
selected_transmission_type = "email";
}
$.each(data.transmission_types, function (k, s) {
var o = $("<option>").attr("value", s.code).text(s.name);
if (selected_transmission_type === s.code) {
o.prop("selected", true);
}
dependents.transmission_type.append(o);
});
}
for (var k in dependents) {
const options = data[k],
dependent = dependents[k];
let visible = 'visible' in options ? options.visible : true;
if (dependent.is("[data-display-dependency]")) {
const dependency = $(dependent.attr("data-display-dependency"));
visible = visible && (
(dependency.attr("type") === 'checkbox' || dependency.attr("type") === 'radio') ? dependency.prop('checked') : !!dependency.val()
);
}
if ('label' in options) {
dependent.closest(".form-group").find(".control-label").text(options.label);
}
const required = 'required' in options && visible && (
(options.required === 'if_any' && isAnyRequired) ||
(options.required === true)
);
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required);
const label = dependent.closest(".form-group").find("label");
const labelRequired = label.find(".label-required");
if (!required) {
labelRequired.remove();
} else if (!labelRequired.length) {
label.append('<i class="label-required">' + gettext('required') + '</i>')
}
}
for (var k in dependents) dependents[k].prop("disabled", false);
}).always(function() {
loader.hide();
}).fail(function(){
// In case of errors, show everything and require nothing, we can still handle errors in backend
for(var k in dependents) {
const dependent = dependents[k],
visible = true,
required = false;
dependent.closest(".form-group").toggle(visible).toggleClass('required', required);
dependent.prop("required", required);
}
});
};
update();
dependency.on("change", update);
dependencies.on("change", update);
});
});
+5 -1
View File
@@ -58,7 +58,11 @@ base_patterns = [
name='metrics'),
re_path(r'^csp_report/$', csp.csp_report, name='csp.report'),
re_path(r'^agpl_source$', source.get_source, name='source'),
re_path(r'^js_helpers/states/$', js_helpers.states, name='js_helpers.states'),
re_path(r'^js_helpers/address_form/$', js_helpers.address_form, name='js_helpers.address_form'),
# deprecated name, keep for compatibility with old plugins
re_path(r'^js_helpers/states/$', js_helpers.address_form, name='js_helpers.states'),
re_path(r'^api/v1/', include(('pretix.api.urls', 'pretixapi'), namespace='api-v1')),
re_path(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',
+1 -1
View File
@@ -67,7 +67,7 @@ def event(organizer, meta_prop):
e = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc),
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf',
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf,tests.testdummy',
is_public=True
)
e.meta_values.create(property=meta_prop, value="Conference")
+28 -2
View File
@@ -28,7 +28,7 @@ import pytest
from django_countries.fields import Country
from django_scopes import scopes_disabled
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models import Invoice, InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice,
@@ -201,6 +201,7 @@ TEST_INVOICE_RES = {
"invoice_from_tax_id": "",
"invoice_from_vat_id": "",
"invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123",
"invoice_to_is_business": False,
"invoice_to_company": "Sample company",
"invoice_to_name": "",
"invoice_to_street": "",
@@ -210,6 +211,7 @@ TEST_INVOICE_RES = {
"invoice_to_country": "NZ",
"invoice_to_vat_id": "DE123",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": None,
"date": "2017-12-10",
"refers": None,
@@ -260,7 +262,11 @@ TEST_INVOICE_RES = {
"tax_name": "",
"tax_rate": "19.00"
}
]
],
"transmission_type": "email",
"transmission_provider": None,
"transmission_status": "pending",
"transmission_date": None
}
@@ -366,6 +372,26 @@ def test_invoice_detail(token_client, organizer, event, item, invoice):
assert res == resp.data
@pytest.mark.django_db
def test_invoice_retransmit(token_client, organizer, event, invoice):
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_INFLIGHT
invoice.save()
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/retransmit/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 409
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
invoice.save()
resp = token_client.post('/api/v1/organizers/{}/events/{}/invoices/{}/retransmit/'.format(
organizer.slug, event.slug, invoice.number
))
assert resp.status_code == 204
invoice.refresh_from_db()
assert invoice.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
@pytest.mark.django_db
def test_invoice_regenerate(token_client, organizer, event, invoice):
organizer.settings.invoice_regenerate_allowed = True
+148 -1
View File
@@ -247,6 +247,147 @@ def test_order_update_state_validation(token_client, organizer, event, order):
assert order.invoice_address.country == "AU"
@pytest.mark.django_db
def test_order_update_transmission_validation(token_client, organizer, event, order):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": False,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"state": "",
"zipcode": "",
"city": "Paris",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "invalid",
"transmission_info": {},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {"transmission_type": ["Unknown transmission type."]}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {
"transmission_type": ["The selected transmission type is not available for this country or address type."]
}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "IT",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {"transmission_info": {
"transmission_it_sdi_pec": ["Enter a valid email address.", "Enter a valid email address."],
"transmission_it_sdi_recipient_code": ["This field is required."]
}}}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "Via Da Vinci 1",
"zipcode": "12345",
"city": "Test",
"country": "IT",
"state": "MI",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar@pec.it",
"transmission_it_sdi_recipient_code": "1234567",
},
}
}
)
assert resp.status_code == 400
assert resp.data == {
"invoice_address": {"vat_id": ["This field is required for the selected type of invoice transmission."]}
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orders/{}/'.format(
organizer.slug, event.slug, order.code
), format='json', data={
'invoice_address': {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "Via Una 1",
"zipcode": "12345",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "peppol",
"transmission_info": {
"transmission_peppol_participant_id": "9930:DE811569869",
"ignored": "parameter",
},
}
}
)
assert resp.status_code == 200
order.invoice_address.refresh_from_db()
assert order.invoice_address.transmission_type == "peppol"
assert order.invoice_address.transmission_info == {
"transmission_peppol_participant_id": "9930:DE811569869",
}
@pytest.mark.django_db
def test_order_update_allowed_fields(token_client, organizer, event, order):
event.settings.locales = ['de', 'en']
@@ -442,6 +583,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
"invoice_from_vat_id": "",
"invoice_to": "Sample company\nNew Zealand\nVAT-ID: DE123",
"invoice_to_company": "Sample company",
"invoice_to_is_business": False,
"invoice_to_name": "",
"invoice_to_street": "",
"invoice_to_zipcode": "",
@@ -450,6 +592,7 @@ def test_order_create_invoice(token_client, organizer, event, order):
"invoice_to_country": "NZ",
"invoice_to_vat_id": "DE123",
"invoice_to_beneficiary": "",
"invoice_to_transmission_info": {},
"custom_field": None,
'date': now().astimezone(event.timezone).date().isoformat(),
'refers': None,
@@ -500,7 +643,11 @@ def test_order_create_invoice(token_client, organizer, event, order):
'foreign_currency_display': None,
'foreign_currency_rate': None,
'foreign_currency_rate_date': None,
'internal_reference': ''
'internal_reference': '',
'transmission_date': None,
'transmission_provider': None,
'transmission_status': 'pending',
'transmission_type': 'email',
}
resp = token_client.post(
+36 -1
View File
@@ -436,7 +436,9 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'vat_id': '',
'vat_id_validated': False,
'internal_reference': '',
'custom_field': None
'custom_field': None,
'transmission_type': 'email',
'transmission_info': None,
},
'positions': [
{
@@ -590,6 +592,39 @@ def test_order_create_invoice_address_optional(token_client, organizer, event, i
o.invoice_address
@pytest.mark.django_db
def test_order_create_invoice_address_transmission_type_validation(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['invoice_address'] = {
"is_business": True,
"company": "This is my company name",
"name": "John Doe",
"name_parts": {},
"street": "",
"zipcode": "",
"city": "Test",
"country": "FR",
"internal_reference": "",
"vat_id": "",
"transmission_type": "it_sdi",
"transmission_info": {
"transmission_it_sdi_pec": "foobar@pec.it",
"transmission_it_sdi_recipient_code": "1234567",
},
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {"invoice_address": {
"transmission_type": ["The selected transmission type is not available for this country or address type."]
}}
@pytest.mark.django_db
def test_order_create_sales_channel_optional(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
+3 -1
View File
@@ -325,7 +325,9 @@ TEST_ORDER_RES = {
"internal_reference": "",
"custom_field": "Custom info",
"vat_id": "DE123",
"vat_id_validated": True
"vat_id_validated": True,
"transmission_type": "email",
"transmission_info": None,
},
"require_approval": False,
"valid_if_pending": False,
+1
View File
@@ -75,6 +75,7 @@ event_permission_sub_urls = [
('get', 'can_view_orders', 'invoices/1/', 404),
('post', 'can_change_orders', 'invoices/1/regenerate/', 404),
('post', 'can_change_orders', 'invoices/1/reissue/', 404),
('post', 'can_change_orders', 'invoices/1/retransmit/', 404),
('get', 'can_view_orders', 'waitinglistentries/', 200),
('get', 'can_view_orders', 'waitinglistentries/1/', 404),
('post', 'can_change_orders', 'waitinglistentries/', 400),
+234
View File
@@ -0,0 +1,234 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Event, Organizer
@pytest.mark.django_db
def test_no_invoice_address(client):
response = client.get('/js_helpers/address_form/?country=DE')
assert response.json() == {
'city': {'required': 'if_any'},
'data': [],
'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'},
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': 'if_any'}
}
response = client.get('/js_helpers/address_form/?country=CR')
assert response.json() == {
'city': {'required': False},
'data': [],
'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'},
'vat_id': {'required': False, 'visible': False},
'zipcode': {'required': False}
}
response = client.get('/js_helpers/address_form/?country=US')
d = response.json()
assert d['state'] == {'label': 'State', 'required': 'if_any', 'visible': True}
assert d['data'][0] == {'code': 'AL', 'name': 'Alabama'}
response = client.get('/js_helpers/address_form/?country=IT')
d = response.json()
assert d['state'] == {'label': 'Province', 'required': 'if_any', 'visible': True}
assert d['data'][0] == {'code': 'AG', 'name': 'Agrigento'}
@pytest.fixture
@scopes_disabled()
def event():
o = Organizer.objects.create(name='Dummy', slug='org')
event = Event.objects.create(
organizer=o, name='Dummy', slug='ev',
date_from=now(), plugins='tests.testdummy'
)
return event
@pytest.mark.django_db
def test_invalid_event(client):
response = client.get(
'/js_helpers/address_form/?country=DE&invoice=true&organizer=test&event=test'
)
assert response.status_code == 404
@pytest.mark.django_db
def test_provider_only_email_available(client, event):
response = client.get(
'/js_helpers/address_form/?country=DE&invoice=true&organizer=org&event=ev&transmission_type_required=true'
)
assert response.status_code == 200
d = response.json()
assert d == {
'city': {'required': 'if_any'},
'data': [],
'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'},
'transmission_email_address': {'required': False, 'visible': False},
'transmission_email_other': {'required': False, 'visible': False},
'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': False},
'transmission_it_sdi_pec': {'required': False, 'visible': False},
'transmission_it_sdi_recipient_code': {'required': False, 'visible': False},
'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': False},
'transmission_types': [{'code': 'email', 'name': 'Email'}],
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': 'if_any'}
}
@pytest.mark.django_db
def test_provider_italy_sdi_not_enforced_when_optional(client, event):
response = client.get(
'/js_helpers/address_form/?country=IT&invoice=true&organizer=org&event=ev&transmission_type_required=false'
)
assert response.status_code == 200
d = response.json()
del d['data']
assert d == {
'city': {'required': 'if_any'},
'state': {'label': 'Province', 'required': 'if_any', 'visible': True},
'street': {'required': 'if_any'},
'transmission_email_address': {'required': False, 'visible': False},
'transmission_email_other': {'required': False, 'visible': False},
'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': False},
'transmission_it_sdi_pec': {'required': False, 'visible': False},
'transmission_it_sdi_recipient_code': {'required': False, 'visible': False},
'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}],
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': 'if_any'}
}
@pytest.mark.django_db
def test_provider_italy_sdi_enforced_individual(client, event):
response = client.get(
'/js_helpers/address_form/?country=IT&invoice=true&organizer=org&event=ev&transmission_type_required=true'
)
assert response.status_code == 200
d = response.json()
del d['data']
assert d == {
'city': {'required': True},
'state': {'label': 'Province', 'required': True, 'visible': True},
'street': {'required': True},
'transmission_email_address': {'required': False, 'visible': False},
'transmission_email_other': {'required': False, 'visible': False},
'transmission_it_sdi_codice_fiscale': {'required': True, 'visible': True},
'transmission_it_sdi_pec': {'required': False, 'visible': True},
'transmission_it_sdi_recipient_code': {'required': False, 'visible': False},
'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}],
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': True}
}
@pytest.mark.django_db
def test_provider_italy_sdi_enforced_business(client, event):
response = client.get(
'/js_helpers/address_form/?country=IT&invoice=true&organizer=org&event=ev&transmission_type_required=true'
'&is_business=business'
)
assert response.status_code == 200
d = response.json()
del d['data']
assert d == {
'city': {'required': True},
'state': {'label': 'Province', 'required': True, 'visible': True},
'street': {'required': True},
'transmission_email_address': {'required': False, 'visible': False},
'transmission_email_other': {'required': False, 'visible': False},
'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': True},
'transmission_it_sdi_pec': {'required': True, 'visible': True},
'transmission_it_sdi_recipient_code': {'required': True, 'visible': True},
'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}],
'vat_id': {'required': True, 'visible': True},
'zipcode': {'required': True}
}
@pytest.mark.django_db
def test_email_peppol_choice(client, event):
response = client.get(
'/js_helpers/address_form/?country=DE&invoice=true&organizer=org&event=ev'
'&is_business=business&transmission_type_required=true'
)
assert response.status_code == 200
d = response.json()
assert d == {
'city': {'required': 'if_any'},
'data': [],
'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'},
'transmission_email_address': {'required': False, 'visible': True},
'transmission_email_other': {'required': False, 'visible': True},
'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': False},
'transmission_it_sdi_pec': {'required': False, 'visible': False},
'transmission_it_sdi_recipient_code': {'required': False, 'visible': False},
'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True},
'transmission_types': [
{'code': 'email', 'name': 'Email'},
{'code': 'peppol', 'name': 'PEPPOL'},
],
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': 'if_any'}
}
response = client.get(
'/js_helpers/address_form/?country=DE&invoice=true&organizer=org&event=ev'
'&is_business=business&transmission_type=peppol'
)
assert response.status_code == 200
d = response.json()
assert d == {
'city': {'required': True},
'data': [],
'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': True},
'transmission_email_address': {'required': False, 'visible': False},
'transmission_email_other': {'required': False, 'visible': False},
'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': False},
'transmission_it_sdi_pec': {'required': False, 'visible': False},
'transmission_it_sdi_recipient_code': {'required': False, 'visible': False},
'transmission_peppol_participant_id': {'required': True, 'visible': True},
'transmission_type': {'visible': True},
'transmission_types': [
{'code': 'email', 'name': 'Email'},
{'code': 'peppol', 'name': 'PEPPOL'},
],
'vat_id': {'required': False, 'visible': True},
'zipcode': {'required': True}
}
+277 -3
View File
@@ -34,12 +34,13 @@ from django.utils.timezone import make_aware, now
from django_countries.fields import Country
from django_scopes import scope
from freezegun import freeze_time
from i18nfield.strings import LazyI18nString
from tests.testdummy.signals import FoobazSalesChannel
from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Item, Order, OrderPosition,
Organizer, SeatingPlan,
CartPosition, Event, GiftCard, Invoice, InvoiceAddress, Item, Order,
OrderPosition, Organizer, SeatingPlan,
)
from pretix.base.models.items import SubEventItem
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
@@ -48,7 +49,9 @@ from pretix.base.payment import (
)
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services.invoices import generate_invoice
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice,
)
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _create_order, approve_order, cancel_order,
deny_order, expire_orders, reactivate_order, send_download_reminders,
@@ -573,6 +576,83 @@ def test_approve_send_to_attendees(event):
assert 'awaiting payment' not in djmail.outbox[1].subject
@pytest.mark.django_db
def test_approve_mail_invoice_attached(event):
djmail.outbox = []
event.settings.invoice_address_asked = True
event.settings.invoice_address_required = True
event.settings.invoice_generate = "True"
event.settings.invoice_email_attachment = True
o1 = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() - timedelta(days=10),
total=10, require_approval=True, locale='en',
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o1, item=ticket, variation=None, price=Decimal("23.00"),
attendee_name_parts={'full_name': "Peter"},
positionid=1
)
InvoiceAddress.objects.create(
order=o1,
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={}
)
o1.create_transactions()
assert o1.transactions.count() == 0
approve_order(o1)
o1.refresh_from_db()
assert len(djmail.outbox) == 1
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
@pytest.mark.django_db(transaction=True)
def test_approve_mail_invoice_sent_somewhere_else(event):
djmail.outbox = []
event.settings.invoice_address_asked = True
event.settings.invoice_address_required = True
event.settings.invoice_generate = "True"
event.settings.invoice_email_attachment = True
o1 = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() - timedelta(days=10),
total=10, require_approval=True, locale='en',
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o1, item=ticket, variation=None, price=Decimal("23.00"),
attendee_name_parts={'full_name': "Peter"},
positionid=1
)
InvoiceAddress.objects.create(
order=o1,
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
o1.create_transactions()
assert o1.transactions.count() == 0
approve_order(o1)
o1.refresh_from_db()
assert len(djmail.outbox) == 2
assert ["invoice@example.org"] == djmail.outbox[0].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
assert ["dummy@dummy.test"] == djmail.outbox[1].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
@pytest.mark.django_db
def test_approve_free(event):
djmail.outbox = []
@@ -670,6 +750,67 @@ def test_deny(event):
assert 'denied' in djmail.outbox[0].subject
@pytest.mark.django_db(transaction=True)
def test_mark_invoices_as_sent(event):
djmail.outbox = []
event.settings.invoice_address_asked = True
event.settings.invoice_address_required = True
event.settings.invoice_generate = "True"
event.settings.invoice_email_attachment = True
o1 = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() - timedelta(days=10),
total=10, locale='en',
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
default_price=Decimal('23.00'), admission=True)
OrderPosition.objects.create(
order=o1, item=ticket, variation=None, price=Decimal("23.00"),
attendee_name_parts={'full_name': "Peter"},
positionid=1
)
ia = InvoiceAddress.objects.create(
order=o1,
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
o1.create_transactions()
i = generate_invoice(o1)
assert i.transmission_type == "email"
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
assert not i.transmission_provider
# Not marked as sent because it is not the right address
o1.send_mail(
subject=LazyI18nString({"en": "Hey"}),
template=LazyI18nString({"en": "Just wanted to send this invoice"}),
context={},
invoices=[i]
)
i.refresh_from_db()
assert i.transmission_type == "email"
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
# If no other address is there, order address will be accepted
ia.transmission_info = {}
ia.save()
o1.send_mail(
subject=LazyI18nString({"en": "Hey"}),
template=LazyI18nString({"en": "Just wanted to send this invoice"}),
context={},
invoices=[i]
)
i.refresh_from_db()
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_COMPLETED
assert i.transmission_provider == "email_pdf"
class PaymentReminderTests(TestCase):
def setUp(self):
super().setUp()
@@ -1033,6 +1174,12 @@ class DownloadReminderTests(TestCase):
assert len(djmail.outbox) == 0
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class OrderCancelTests(TestCase):
def setUp(self):
super().setUp()
@@ -1060,6 +1207,7 @@ class OrderCancelTests(TestCase):
self.order.create_transactions()
generate_invoice(self.order)
djmail.outbox = []
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
@classscope(attr='o')
def test_cancel_canceled(self):
@@ -1111,6 +1259,56 @@ class OrderCancelTests(TestCase):
assert self.order.transactions.count() == 4
assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00')
@classscope(attr='o')
def test_cancel_mail_invoice_attached(self):
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
InvoiceAddress.objects.update_or_create(
order=self.order,
defaults=dict(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={}
)
)
self.order.status = Order.STATUS_PAID
self.order.save()
djmail.outbox = []
cancel_order(self.order.pk, send_mail=True)
assert len(djmail.outbox) == 1
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
@classscope(attr='o')
def test_cancel_mail_invoice_sent_somewhere_else(self):
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
InvoiceAddress.objects.update_or_create(
order=self.order,
defaults=dict(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
)
# Recreate invoice because otherwise it will be sent where the original was sent
generate_cancellation(self.order.invoices.get())
generate_invoice(self.order)
self.order.status = Order.STATUS_PAID
self.order.save()
djmail.outbox = []
cancel_order(self.order.pk, send_mail=True)
print([s.subject for s in djmail.outbox])
print([s.to for s in djmail.outbox])
assert len(djmail.outbox) == 2
assert ["invoice@example.org"] == djmail.outbox[0].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
assert ["dummy@dummy.test"] == djmail.outbox[1].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
@classscope(attr='o')
def test_cancel_paid_with_too_high_fee(self):
self.order.status = Order.STATUS_PAID
@@ -1239,6 +1437,7 @@ class OrderCancelTests(TestCase):
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
@pytest.mark.usefixtures("class_monkeypatch")
class OrderChangeManagerTests(TestCase):
def setUp(self):
super().setUp()
@@ -1247,6 +1446,7 @@ class OrderChangeManagerTests(TestCase):
self.event = Event.objects.create(organizer=self.o, name='Dummy', slug='dummy', date_from=now(),
plugins='pretix.plugins.banktransfer')
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
self.order = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, locale='en',
@@ -1301,6 +1501,7 @@ class OrderChangeManagerTests(TestCase):
self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1")
self.seat_a2 = self.event.seats.create(seat_number="A2", product=self.stalls, seat_guid="A2")
self.seat_a3 = self.event.seats.create(seat_number="A3", product=self.stalls, seat_guid="A3")
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
def _enable_reverse_charge(self):
self.tr7.eu_reverse_charge = True
@@ -3525,6 +3726,79 @@ class OrderChangeManagerTests(TestCase):
assert self.op1.valid_from is None
assert self.op1.valid_until is None
@classscope(attr='o')
def test_new_invoice_attached(self):
generate_invoice(self.order)
assert self.order.invoices.count() == 1
djmail.outbox = []
self.ocm.add_position(self.ticket, None, Decimal('0.00'))
self.ocm.commit()
assert self.order.invoices.count() == 3
assert len(djmail.outbox) == 1
assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 2
@classscope(attr='o')
def test_new_invoice_send_somewhere_else(self):
generate_invoice(self.order)
assert self.order.invoices.count() == 1
InvoiceAddress.objects.update_or_create(
order=self.order,
defaults=dict(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
)
djmail.outbox = []
self.ocm.add_position(self.ticket, None, Decimal('0.00'))
self.ocm.commit()
assert self.order.invoices.count() == 3
# Cancellation is still sent to old method!
assert len(djmail.outbox) == 2
assert ["dummy@dummy.test"] == djmail.outbox[0].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 1
assert ["invoice@example.org"] == djmail.outbox[1].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[1].attachments]) == 1
@classscope(attr='o')
def test_new_invoice_split_send_somewhere_else(self):
generate_invoice(self.order)
assert self.order.invoices.count() == 1
InvoiceAddress.objects.update_or_create(
order=self.order,
defaults=dict(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_address": "invoice@example.org",
}
)
)
djmail.outbox = []
self.ocm.split(self.op2)
self.ocm.commit()
assert self.order.invoices.count() == 3
# Cancellation is still sent to old method!
assert len(djmail.outbox) == 4
assert ["dummy@dummy.test"] == djmail.outbox[0].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 1
assert ["dummy@dummy.test"] == djmail.outbox[1].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[1].attachments]) == 0
assert ["invoice@example.org"] == djmail.outbox[2].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[2].attachments]) == 1
assert ["invoice@example.org"] == djmail.outbox[3].to
assert len(["Invoice_" in a[0] for a in djmail.outbox[3].attachments]) == 1
@pytest.mark.django_db
def test_saleschannel_testmode_restriction(event):
+18 -8
View File
@@ -208,14 +208,20 @@ def test_invoice_address_shredder(event, order):
'pretix.event.order.modified',
data={
"data": [{"attendee_name": "Hans", "question_1": "Test"}],
"invoice_data": {"name": "Peter", "country": "DE", "is_business": False, "internal_reference": "",
"state": "",
"company": "ACME", "street": "Sesam Street", "city": "Sample City", "zipcode": "12345"}
"invoice_data": {
"name": "Peter", "country": "DE", "is_business": False, "internal_reference": "",
"state": "", "company": "ACME", "street": "Sesam Street", "city": "Sample City", "zipcode": "12345",
"transmission_type": "email", "transmission_info": {"transmission_email_address": "other@example.org"}
}
}
)
ia = InvoiceAddress.objects.create(
company='Acme Company', street='221B Baker Street',
zipcode='12345', city='London', country='UK',
order=order, transmission_type="email", transmission_info={
"transmission_email_address": "other@example.org"
}
)
ia = InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street',
zipcode='12345', city='London', country='UK',
order=order)
s = InvoiceAddressShredder(event)
f = list(s.generate_files())
assert json.loads(f[0][2]) == {
@@ -233,7 +239,9 @@ def test_invoice_address_shredder(event, order):
'street': '221B Baker Street',
'vat_id': '',
'vat_id_validated': False,
'zipcode': '12345'
'zipcode': '12345',
"transmission_type": "email",
"transmission_info": {"transmission_email_address": "other@example.org"}
}
}
s.shred_data()
@@ -243,7 +251,9 @@ def test_invoice_address_shredder(event, order):
assert l1.parsed_data == {
"data": [{"attendee_name": "Hans", "question_1": "Test"}],
"invoice_data": {"name": "", "country": "", "is_business": False, "internal_reference": "", "company": "",
"street": "", "city": "", "zipcode": "", "state": ""}
"street": "", "city": "", "zipcode": "", "state": "",
"transmission_type": "email",
"transmission_info": {"_shredded": True}}
}
+16 -3
View File
@@ -47,9 +47,9 @@ from tests.plugins.stripe.test_checkout import apple_domain_create
from tests.plugins.stripe.test_provider import MockedCharge
from pretix.base.models import (
Event, GiftCard, InvoiceAddress, Item, Order, OrderFee, OrderPayment,
OrderPosition, OrderRefund, Organizer, Question, QuestionAnswer, Quota,
Team, User,
Event, GiftCard, Invoice, InvoiceAddress, Item, Order, OrderFee,
OrderPayment, OrderPosition, OrderRefund, Organizer, Question,
QuestionAnswer, Quota, Team, User,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
@@ -625,6 +625,19 @@ def test_order_invoice_create_ok(client, env):
assert env[2].invoices.exists()
@pytest.mark.django_db
def test_order_invoice_retransmit(client, env):
client.login(email='dummy@dummy.dummy', password='dummy')
with scopes_disabled():
i = generate_invoice(env[2])
i.transmission_status = Invoice.TRANSMISSION_STATUS_FAILED
i.save()
response = client.post('/control/event/dummy/dummy/orders/FOO/invoices/%d/retransmit' % i.pk, {}, follow=True)
assert 'alert-success' in response.content.decode()
i.refresh_from_db()
assert i.transmission_status == Invoice.TRANSMISSION_STATUS_PENDING
@pytest.mark.django_db
def test_order_invoice_regenerate(client, env):
client.login(email='dummy@dummy.dummy', password='dummy')
@@ -1,90 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Flavia Bastos
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# 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.
from django.core import mail as djmail
from django_countries.fields import Country
from django_scopes import scopes_disabled
from tests.presale.test_orders import BaseOrdersTest
from pretix.base.models import InvoiceAddress, OrderPayment
from pretix.base.services.invoices import generate_invoice
class BanktransferOrdersTest(BaseOrdersTest):
def test_unknown_order(self):
response = self.client.post(
'/%s/%s/banktransfer/ABCDE/123/mail-invoice/' % (self.orga.slug, self.event.slug)
)
assert response.status_code == 404
response = self.client.post(
'/%s/%s/banktransfer/%s/123/mail-invoice/' % (self.orga.slug, self.event.slug, self.not_my_order.code)
)
assert response.status_code == 404
def test_order_with_no_invoice(self):
djmail.outbox = []
response = self.client.post(
'/%s/%s/banktransfer/%s/%s/mail-invoice/' % (
self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{'email': 'test@example.org'}
)
assert response.status_code == 302
from django.contrib.messages import get_messages
messages = list(get_messages(response.wsgi_request))
assert len(messages) == 1
assert str(messages[0]) == 'No pending bank transfer payment found. Maybe the order has been paid already?'
assert len(djmail.outbox) == 0
def test_valid_order(self):
with scopes_disabled():
self.event.settings.set('payment_banktransfer_invoice_email', True)
self.order.payments.create(provider='banktransfer', state=OrderPayment.PAYMENT_STATE_CREATED,
amount=self.order.total)
InvoiceAddress.objects.create(order=self.order, company="Sample company", country=Country('NZ'))
generate_invoice(self.order)
djmail.outbox = []
response = self.client.post(
'/%s/%s/banktransfer/%s/%s/mail-invoice/' % (
self.orga.slug, self.event.slug, self.order.code, self.order.secret),
{'email': 'test@example.org'}
)
assert response.status_code == 302
from django.contrib.messages import get_messages
messages = list(get_messages(response.wsgi_request))
assert len(messages) == 1
assert str(messages[0]) == 'Sending the latest invoice via email to test@example.org.'
assert len(djmail.outbox) == 1
+394 -56
View File
@@ -60,6 +60,12 @@ from pretix.testutils.sessions import get_cart_session_key
from .test_timemachine import TimemachineTestMixin
@pytest.fixture
def class_monkeypatch(request, monkeypatch):
request.cls.monkeypatch = monkeypatch
@pytest.mark.usefixtures("class_monkeypatch")
class BaseCheckoutTestCase:
@scopes_disabled()
def setUp(self):
@@ -98,6 +104,7 @@ class BaseCheckoutTestCase:
self.workshopquota.items.add(self.workshop2)
self.workshopquota.variations.add(self.workshop2a)
self.workshopquota.variations.add(self.workshop2b)
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
def _set_session(self, key, value):
session = self.client.session
@@ -169,7 +176,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
assert b'TRACKING SCRIPT' not in payment_r.content
@@ -201,7 +209,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -224,7 +233,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
with scopes_disabled():
@@ -260,7 +270,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
assert 'alert-danger' in resp.content.decode()
@@ -291,7 +302,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'country': 'AU',
'state': 'QLD',
'vat_id': 'AU123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -324,7 +336,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -355,7 +368,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'FR',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
assert 'alert-danger' in resp.content.decode()
@@ -388,7 +402,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -422,7 +437,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -459,7 +475,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -476,7 +493,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -508,7 +526,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate:
@@ -522,7 +541,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
@@ -556,7 +576,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(r.content.decode(), "lxml")
assert doc.select(".alert-danger")
@@ -575,7 +596,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(r.content.decode(), "lxml")
assert not doc.select(".alert-danger")
@@ -605,7 +627,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
@@ -633,7 +656,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -685,7 +709,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '1234',
'city': 'Here',
'country': 'AT',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -714,7 +739,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'US',
'state': 'CA',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
with scopes_disabled():
@@ -767,7 +793,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -831,7 +858,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'AT',
'vat_id': 'AT123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
cr1.refresh_from_db()
@@ -868,7 +896,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
f = SimpleUploadedFile("testfile.txt", b"file_content")
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.id, q1.id): f,
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -883,7 +912,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Delete
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s-clear' % (cr1.id, q1.id): 'on',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
with scopes_disabled():
assert not cr1.answers.exists()
@@ -904,6 +934,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -913,6 +944,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'email': 'admin@localhost',
'phone_0': '+49',
'phone_1': '0622199999', # yeah the 0 is wrong but users don't know that so it should work fine
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -942,7 +974,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_email' % cr1.id: '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -950,7 +983,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_email' % cr1.id: 'foo@localhost',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -974,7 +1008,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-company' % cr1.id: '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -982,7 +1017,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-company' % cr1.id: 'foobar',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1022,7 +1058,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'%s-city' % cr1.id: 'Musterstadt',
'%s-country' % cr1.id: 'DE',
'%s-state' % cr1.id: '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1049,7 +1086,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name_parts_0' % cr1.id: '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1057,7 +1095,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name_parts_0' % cr1.id: 'Peter',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1082,7 +1121,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Accepted request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1112,7 +1152,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'%s-attendee_name_parts_1' % cr1.id: '',
'%s-attendee_name_parts_2' % cr1.id: 'John',
'%s-attendee_name_parts_3' % cr1.id: 'Doe',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
})
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1143,7 +1184,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all fields filled out, expect success
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name_parts_0' % cr1.id: '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1173,7 +1215,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1191,7 +1234,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1233,7 +1277,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '',
'city': '',
'country': 'BI',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1250,7 +1295,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'zipcode': '',
'city': 'Bujumbura',
'country': 'BI',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1293,7 +1339,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1311,11 +1358,168 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_invoice_transmission_providers_email_special_case(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="transmission_email_other"]')), 1)
self.event.settings.invoice_generate = "False"
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="transmission_email_other"]')), 0)
def test_invoice_transmission_providers_all_fields_loaded(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="transmission_email_address"]')), 1)
self.assertEqual(len(doc.select('input[name="transmission_email_other"]')), 1)
self.assertEqual(len(doc.select('input[name="transmission_it_sdi_codice_fiscale"]')), 1)
self.assertEqual(len(doc.select('input[name="transmission_it_sdi_pec"]')), 1)
self.assertEqual(len(doc.select('input[name="transmission_it_sdi_recipient_code"]')), 1)
self.assertEqual(len(doc.select('input[name="transmission_peppol_participant_id"]')), 1)
self.assertEqual(len(doc.select('select[name="transmission_type"]')), 1)
self.assertEqual(len(doc.select('select[name="transmission_type"] option')), 3)
def test_invoice_transmission_invalid_submission(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
self.event.settings.invoice_address_vatid = True
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost',
'transmission_type': 'it_sdi',
}, follow=True)
assert "The selected transmission type is not available" in response.content.decode()
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'IT',
'state': 'MI',
'email': 'admin@localhost',
'transmission_type': 'it_sdi',
'vat_id': '',
}, follow=True)
assert "This field is required for the selected type" in response.content.decode()
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'IT',
'state': 'MI',
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
assert "must be used for this country" in response.content.decode()
def test_invoice_optional_transmission(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = False
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
self.event.settings.invoice_address_vatid = True
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost',
'transmission_type': '-',
}, follow=True)
assert "If you enter an invoice address, you also need to select an invoice" in response.content.decode()
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': '',
'name_parts_0': '',
'name_parts_1': '',
'name_parts_2': '',
'name_parts_3': '',
'street': '',
'zipcode': '',
'city': '',
'country': 'IT',
'state': '',
'email': 'admin@localhost',
'transmission_type': '-',
'vat_id': '',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
ia = InvoiceAddress.objects.get()
assert ia.transmission_type == "email"
def test_invoice_address_hidden_for_free(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
@@ -1332,7 +1536,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
self.assertEqual(len(doc.select('input[name="city"]')), 0)
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1365,7 +1570,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1406,7 +1612,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'is_business': 'business',
'country': 'DE',
'city': 'Musterstadt',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1416,7 +1623,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'is_business': 'business',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -1437,7 +1645,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -1445,7 +1654,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'name_parts_0': 'Raphael',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -2603,7 +2813,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Date too far in the future, expected to fail
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2024-01-20',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -2611,7 +2822,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2023-01-20',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -2649,7 +2861,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from_0' % cr1.id: '2024-01-20',
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -2658,7 +2871,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from_0' % cr1.id: '2023-01-20',
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -2701,7 +2915,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Date too far in the future, expected to fail
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2024-01-17',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -2709,7 +2924,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2023-01-10',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -2748,7 +2964,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
)
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -3797,6 +4014,120 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
o.payments.first().confirm()
assert len(djmail.outbox) == 0
def test_order_confirmation_mail_invoice_attached(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
ia = InvoiceAddress.objects.create(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_other": False,
"transmission_email_address": None,
}
)
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
djmail.outbox = []
_perform_order(self.event, self._manual_payment(), [cp1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
assert len(djmail.outbox) == 1
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
def test_order_confirmation_mail_invoice_sent_somewhere_else(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "True"
self.event.settings.invoice_email_attachment = True
ia = InvoiceAddress.objects.create(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_other": True,
"transmission_email_address": "invoice@example.org",
}
)
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
djmail.outbox = []
_perform_order(self.event, self._manual_payment(), [cp1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
assert len(djmail.outbox) == 2
assert ["invoice@example.org"] == djmail.outbox[0].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
assert ["admin@example.org"] == djmail.outbox[1].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
def test_order_paid_confirmation_mail_invoice_attached(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "paid"
self.event.settings.invoice_email_attachment = True
ia = InvoiceAddress.objects.create(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_other": False,
"transmission_email_address": None,
}
)
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
djmail.outbox = []
oid = _perform_order(self.event, self._manual_payment(), [cp1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
assert len(djmail.outbox) == 1
assert not any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
o = Order.objects.get(pk=oid['order_id'])
o.payments.first().confirm()
assert len(djmail.outbox) == 2
assert any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
def test_order_paid_confirmation_mail_invoice_sent_somewhere_else(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_generate = "paid"
self.event.settings.invoice_email_attachment = True
ia = InvoiceAddress.objects.create(
is_business=True,
country=Country('AT'),
transmission_type="email",
transmission_info={
"transmission_email_other": True,
"transmission_email_address": "invoice@example.org",
}
)
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
djmail.outbox = []
oid = _perform_order(self.event, self._manual_payment(), [cp1.pk], 'admin@example.org', 'en', ia.pk, {}, 'web')
assert len(djmail.outbox) == 1
assert ["admin@example.org"] == djmail.outbox[0].to
assert not any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
o = Order.objects.get(pk=oid['order_id'])
o.payments.first().confirm()
assert len(djmail.outbox) == 3
assert ["invoice@example.org"] == djmail.outbox[2].to
assert any(["Invoice_" in a[0] for a in djmail.outbox[2].attachments])
def test_locale_region_not_saved(self):
self.event.settings.origin = 'US'
self.event.settings.locales = ['de']
@@ -3882,6 +4213,7 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
'%s-question_%s_0' % (cr.id, q3.id): '2018-01-01',
'%s-question_%s_1' % (cr.id, q3.id): '5:23',
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), target_status_code=200)
self.event.settings.set('timezone', 'US/Central')
@@ -3966,7 +4298,8 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
'%s-question_%s' % (cr2.id, q1.id): '',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -3977,7 +4310,8 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
'%s-question_%s' % (cr2.id, q1.id): '0',
'%s-question_%s' % (cr1.id, q2.id): 'Internet',
'%s-question_%s' % (cr2.id, q2.id): '',
'email': 'admin@localhost'
'email': 'admin@localhost',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -4000,6 +4334,7 @@ class QuestionsTestCase(BaseCheckoutTestCase, TestCase):
('%s-question_%s' % (cr1.id, k.id)): v for k, v in data.items() if v != 'False'
}
pl['email'] = 'admin@localhost'
pl['transmission_type'] = 'email'
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), pl, follow=True)
if should_fail:
doc = BeautifulSoup(response.content.decode(), "lxml")
@@ -4919,7 +5254,8 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'will-be-ignored'
'email': 'will-be-ignored',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -5003,7 +5339,8 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
target_status_code=200)
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'will-be-ignored'
'email': 'will-be-ignored',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -5140,6 +5477,7 @@ class CustomerCheckoutTestCase(BaseCheckoutTestCase, TestCase):
assert b'Mark Fisher' not in response.content
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'will-be-ignored',
'transmission_type': 'email',
f'{cp.pk}-attendee_name_parts_0': 'will-be-ignored'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
+15 -1
View File
@@ -273,6 +273,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-attendee_name_parts_0' % self.ticket_pos.id: '',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -304,6 +305,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-attendee_name_parts_0' % self.ticket_pos.id: 'Peter',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response, '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.order.secret),
@@ -326,6 +328,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): '',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -350,6 +353,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): '',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -357,6 +361,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -374,6 +379,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -390,6 +396,7 @@ class OrdersTest(BaseOrdersTest):
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
'transmission_type': 'email',
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
@@ -403,6 +410,7 @@ class OrdersTest(BaseOrdersTest):
response = self.client.post(
'/%s/%s/order/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), {
'%s-question_%s' % (self.ticket_pos.id, self.question.id): 'ABC',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -419,6 +427,7 @@ class OrdersTest(BaseOrdersTest):
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -436,6 +445,7 @@ class OrdersTest(BaseOrdersTest):
'street': 'Main Street',
'city': 'Heidelberg',
'country': 'DE',
'transmission_type': 'email',
}, follow=True)
self.assertRedirects(response,
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
@@ -477,8 +487,12 @@ class OrdersTest(BaseOrdersTest):
# Not all fields filled out, expect success
response = self.client.post(
'/%s/%s/ticket/%s/%s/%s/modify' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret)
self.ticket_pos.positionid, self.ticket_pos.web_secret),
{
'transmission_type': 'email',
}
)
print(response.content.decode())
self.assertRedirects(response,
'/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret),
+38
View File
@@ -22,6 +22,10 @@
from django.dispatch import receiver
from pretix.base.channels import SalesChannelType
from pretix.base.invoicing.transmission import (
TransmissionProvider, transmission_providers,
)
from pretix.base.models import Invoice
from pretix.base.signals import (
register_payment_providers, register_sales_channel_types,
register_ticket_outputs,
@@ -70,3 +74,37 @@ def html_head_presale(sender, request=None, **kwargs):
# No tracking scripts on PCI DSS relevant payment pages
return ""
return "<script>alert('BAD TRACKING SCRIPT')</script>"
@transmission_providers.new()
class TestSdiTransmissionProvider(TransmissionProvider):
identifier = "fatturapa_test"
type = "it_sdi"
verbose_name = "FatturaPA Test"
def is_ready(self, event) -> bool:
return True
def is_available(self, event, country, is_business: bool) -> bool:
return str(country) == "IT"
def transmit(self, invoice):
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
invoice.save()
@transmission_providers.new()
class TestPeppolTransmissionProvider(TransmissionProvider):
identifier = "peppol_test"
type = "peppol"
verbose_name = "PEPPOL Test"
def is_ready(self, event) -> bool:
return True
def is_available(self, event, country, is_business: bool) -> bool:
return is_business
def transmit(self, invoice):
invoice.transmission_status = Invoice.TRANSMISSION_STATUS_COMPLETED
invoice.save()