mirror of
https://github.com/pretix/pretix.git
synced 2025-12-06 21:42:49 +00:00
Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b56c00e5 | ||
|
|
cb17febf7c | ||
|
|
07d42a4d77 | ||
|
|
e3ebf887a4 | ||
|
|
0440187e59 | ||
|
|
dfcda0fa2c | ||
|
|
560c0a8729 | ||
|
|
bc80b60b04 | ||
|
|
08bf3648ea | ||
|
|
f8ee7acad6 | ||
|
|
10c86869ea | ||
|
|
9034a98df9 | ||
|
|
a7142fdf55 | ||
|
|
ee97c46aec | ||
|
|
7063f32f24 | ||
|
|
2ec926b7c7 | ||
|
|
834b5a26a5 | ||
|
|
90f08d0aca | ||
|
|
d5c2637198 | ||
|
|
f517ba51bd | ||
|
|
d738198ec5 | ||
|
|
b1ce58d06c | ||
|
|
b26ef74128 | ||
|
|
4f8c8ea917 | ||
|
|
0803b049af | ||
|
|
97f3fbdb80 | ||
|
|
434b6e4729 | ||
|
|
f56bceb55f | ||
|
|
2aa246b3d5 | ||
|
|
f77b551aa6 | ||
|
|
c9415cba2b | ||
|
|
4dae224d73 | ||
|
|
13cc57e98b | ||
|
|
6f980b82ac | ||
|
|
f32c581a9e | ||
|
|
fcadfffb92 | ||
|
|
9e43459879 | ||
|
|
87424c25de | ||
|
|
acdf7d62b5 | ||
|
|
944138f7a9 | ||
|
|
5da2eab1fb | ||
|
|
d680937a6c | ||
|
|
f35c2544b6 | ||
|
|
0285cd12f7 | ||
|
|
03cacace57 | ||
|
|
6ed016e49e | ||
|
|
da8da01614 | ||
|
|
9a2ea6699a | ||
|
|
51a8bac9e6 | ||
|
|
303ed07504 | ||
|
|
c7627f631f | ||
|
|
604c31c6e2 | ||
|
|
c3da6731a1 | ||
|
|
6e556ab09b | ||
|
|
16622883f6 | ||
|
|
cce4379d3e | ||
|
|
5af99f4f1a | ||
|
|
9ed49888b8 | ||
|
|
5bfb00db73 | ||
|
|
a031d72ca9 | ||
|
|
15a190cdf3 | ||
|
|
d181375479 | ||
|
|
d8a57b0baa | ||
|
|
d482bc9de0 | ||
|
|
5c030796d7 | ||
|
|
f6eb3bfb80 | ||
|
|
3703fbcacf | ||
|
|
cdea6eb55e | ||
|
|
bf1e9d47d0 | ||
|
|
350df2a3cc | ||
|
|
bc6915b251 | ||
|
|
f9c7eeff9a | ||
|
|
247bcf0a20 | ||
|
|
455c961fc7 | ||
|
|
9052d4a7a9 | ||
|
|
589401e8d2 | ||
|
|
0c366a8473 | ||
|
|
c9ddbd0e88 | ||
|
|
31bf0c24f1 | ||
|
|
c74386346b | ||
|
|
725e1f019e | ||
|
|
06eddb2c6d | ||
|
|
80b5750756 | ||
|
|
f37d265534 | ||
|
|
7c4a1e5fb8 | ||
|
|
9a045c76ec | ||
|
|
447b36fdd3 | ||
|
|
5dbd984178 | ||
|
|
95f96f8321 | ||
|
|
3933032778 | ||
|
|
d0b18d9f64 | ||
|
|
71de71ed37 | ||
|
|
3438d079d5 | ||
|
|
e7730333c2 | ||
|
|
e8b9f0a3ae | ||
|
|
77ebd18404 | ||
|
|
2d48198c83 | ||
|
|
d103b0bb84 | ||
|
|
01411b84e4 | ||
|
|
b7e154d8c9 | ||
|
|
f39ac96322 | ||
|
|
74db808978 | ||
|
|
ab72b93706 | ||
|
|
af5aece639 | ||
|
|
228ab15900 | ||
|
|
66164d8202 | ||
|
|
d5ac155914 | ||
|
|
75a966529e | ||
|
|
28a6a6185d | ||
|
|
07cdaa9ca9 | ||
|
|
1c6935ebd9 | ||
|
|
60c1ea8aad | ||
|
|
0b8798a65c | ||
|
|
a8836cbeec | ||
|
|
336a34b10b | ||
|
|
c5862cc0a0 | ||
|
|
89cdcd3781 | ||
|
|
2837cac554 | ||
|
|
3b54556739 | ||
|
|
4d6d6ff737 | ||
|
|
ffee31e415 | ||
|
|
8abfbba9d0 | ||
|
|
588955901c | ||
|
|
4b7bf2f27f | ||
|
|
664957e886 | ||
|
|
f15a6d39c3 | ||
|
|
3fd80a9a46 | ||
|
|
2fd2716303 | ||
|
|
37315fc380 | ||
|
|
f96fc0744e | ||
|
|
5bb7883020 | ||
|
|
3f95434922 | ||
|
|
08da5a8b91 | ||
|
|
97dc4421ea | ||
|
|
26ca2ff006 | ||
|
|
980c359f57 | ||
|
|
ff1198dec6 | ||
|
|
7275de94af | ||
|
|
ed46f41f8c | ||
|
|
1078e38890 | ||
|
|
2e9bbff308 | ||
|
|
13a48701fa | ||
|
|
ddc9c850c0 | ||
|
|
fa0dae6ed6 | ||
|
|
da6176a51e | ||
|
|
4ef6659551 | ||
|
|
82624a1dc0 | ||
|
|
b50add260a | ||
|
|
f72f97d366 | ||
|
|
ad46e9e541 | ||
|
|
343dbc00be | ||
|
|
3cb94f702d | ||
|
|
ddeae224fb | ||
|
|
3c57895101 | ||
|
|
687c85eb58 | ||
|
|
90ffdbdfa3 | ||
|
|
654be0db34 | ||
|
|
82e3359b40 | ||
|
|
01a6861453 | ||
|
|
7f6cdd6241 | ||
|
|
aad1fda31f | ||
|
|
ad462921f0 | ||
|
|
dc433f6420 | ||
|
|
2d8b3d1c79 | ||
|
|
eb85fa956e | ||
|
|
215514fca7 | ||
|
|
3fe2dfe810 | ||
|
|
041d05eb66 | ||
|
|
d05530ddfc | ||
|
|
734e77d1a3 | ||
|
|
633061e203 | ||
|
|
e11ee4a427 | ||
|
|
1edcd47703 | ||
|
|
cf4b2544f2 | ||
|
|
04c3cffd43 | ||
|
|
483d41c7a6 | ||
|
|
b0c4c88d01 | ||
|
|
518298f71c | ||
|
|
62c2e7765b | ||
|
|
2bb2a40509 | ||
|
|
49828186b0 | ||
|
|
c07a6cb4aa | ||
|
|
67ad9a0dcb | ||
|
|
d267dfc682 | ||
|
|
eed220f14a | ||
|
|
85289fe0d1 | ||
|
|
6293ad34d4 | ||
|
|
0dc4f61cf0 | ||
|
|
6849e682d7 |
@@ -38,7 +38,7 @@ if [ "$1" == "translation-spelling" ]; then
|
||||
potypo
|
||||
fi
|
||||
if [ "$1" == "tests" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
|
||||
@@ -64,7 +64,7 @@ To build and run pretix, you will need the following debian packages::
|
||||
|
||||
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
|
||||
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev
|
||||
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
|
||||
|
||||
Config file
|
||||
-----------
|
||||
|
||||
@@ -445,6 +445,8 @@ Order position endpoints
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
``checkins`` value will only include check-ins for the selected list.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -516,6 +518,8 @@ Order position endpoints
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
|
||||
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
|
||||
@@ -64,6 +64,12 @@ original_price money (string) An original pri
|
||||
require_approval boolean If ``True``, orders with this product will need to be
|
||||
approved by the event organizer before they can be
|
||||
paid.
|
||||
generate_tickets boolean If ``False``, tickets are never generated for this
|
||||
product, regardless of other settings. If ``True``,
|
||||
tickets are generated even if this is a
|
||||
non-admission or add-on product, regardless of event
|
||||
settings. If this is ``null``, regular ticketing
|
||||
rules apply.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
@@ -111,6 +117,10 @@ addons list of objects Definition of a
|
||||
|
||||
The ``sales_channels`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4
|
||||
|
||||
The ``generate_tickets`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Please note that an item either always has variations or never has. Once created with variations the item can never
|
||||
@@ -174,6 +184,7 @@ Endpoints
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": false,
|
||||
"generate_tickets": null,
|
||||
"require_approval": false,
|
||||
"variations": [
|
||||
{
|
||||
@@ -256,6 +267,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -323,6 +335,7 @@ Endpoints
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"allow_cancel": true,
|
||||
"generate_tickets": null,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"checkin_attention": false,
|
||||
@@ -379,6 +392,7 @@ Endpoints
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
"generate_tickets": null,
|
||||
"checkin_attention": false,
|
||||
"has_variations": true,
|
||||
"require_approval": false,
|
||||
@@ -462,6 +476,7 @@ Endpoints
|
||||
"available_until": null,
|
||||
"require_voucher": false,
|
||||
"hide_without_voucher": false,
|
||||
"generate_tickets": null,
|
||||
"allow_cancel": true,
|
||||
"min_per_order": null,
|
||||
"max_per_order": null,
|
||||
|
||||
@@ -26,7 +26,6 @@ status string Order status, o
|
||||
* ``p`` – paid
|
||||
* ``e`` – expired
|
||||
* ``c`` – canceled
|
||||
* ``r`` – refunded
|
||||
secret string The secret contained in the link sent to the customer
|
||||
email string The customer email address
|
||||
locale string The locale used for communication with this customer
|
||||
@@ -58,9 +57,9 @@ invoice_address object Invoice address
|
||||
└ 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.
|
||||
positions list of objects List of order positions (see below)
|
||||
fees list of objects List of fees included in the order total (i.e.
|
||||
payment fees)
|
||||
positions list of objects List of non-canceled order positions (see below)
|
||||
fees list of objects List of non-canceled fees included in the order total
|
||||
(i.e. payment fees)
|
||||
├ fee_type string Type of fee (currently ``payment``, ``passbook``,
|
||||
``other``)
|
||||
├ value money (string) Fee amount
|
||||
@@ -127,6 +126,11 @@ last_modified datetime Last modificati
|
||||
|
||||
The ``sales_channel`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2.4:
|
||||
|
||||
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
|
||||
``…/mark_refunded/`` has been deprecated.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -776,7 +780,10 @@ Order state operations
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/
|
||||
|
||||
Marks a pending order as canceled.
|
||||
Cancels an order. For a pending order, this will set the order to status ``c``. For a paid order, this will set
|
||||
the order to status ``c`` if no ``cancellation_fee`` is passed. If you do pass a ``cancellation_fee``, the order
|
||||
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
|
||||
fee as the only component of the order.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -788,7 +795,8 @@ Order state operations
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"send_email": true
|
||||
"send_email": true,
|
||||
"cancellation_fee": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -849,44 +857,6 @@ Order state operations
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_refunded/
|
||||
|
||||
Marks a paid order as refunded.
|
||||
|
||||
.. warning:: In the current implementation, this will **bypass** the payment provider, i.e. the money will **not** be
|
||||
transferred back to the user automatically, the order will only be *marked* as refunded within pretix.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": "ABC12",
|
||||
"status": "r",
|
||||
...
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param code: The ``code`` field of the order to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The order cannot be marked as expired since the current order status does not allow it.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested order does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
|
||||
|
||||
Marks a unpaid order as expired.
|
||||
@@ -1066,6 +1036,8 @@ List of all order positions
|
||||
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
|
||||
``pseudonymization_id``.
|
||||
|
||||
.. note:: Individually canceled order positions are currently not visible via the API at all.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
|
||||
|
||||
Returns a list of all order positions within a given event.
|
||||
@@ -1501,7 +1473,7 @@ Order payment endpoints
|
||||
|
||||
{
|
||||
"amount": "23.00",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
|
||||
@@ -1648,7 +1620,7 @@ Order refund endpoints
|
||||
"payment": 1,
|
||||
"execution_date": null,
|
||||
"provider": "manual",
|
||||
"mark_refunded": false
|
||||
"mark_canceled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -1718,7 +1690,7 @@ Order refund endpoints
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
|
||||
|
||||
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
|
||||
Acts on an external refund, either marks the order as canceled or pending. Only allowed in state ``external``.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -1729,7 +1701,7 @@ Order refund endpoints
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{"mark_refunded": false}
|
||||
{"mark_canceled": false}
|
||||
|
||||
**Example response**:
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.order.contact.changed``
|
||||
* ``pretix.event.order.changed.*``
|
||||
* ``pretix.event.order.refund.created.externally``
|
||||
* ``pretix.event.order.refunded``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
|
||||
@@ -26,7 +26,7 @@ Frontend
|
||||
--------
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
:members: html_head, html_footer, footer_links, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content
|
||||
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional
|
||||
|
||||
|
||||
.. automodule:: pretix.presale.signals
|
||||
@@ -49,7 +49,7 @@ Backend
|
||||
|
||||
.. automodule:: pretix.control.signals
|
||||
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
|
||||
order_info, event_settings_widget, oauth_application_registered
|
||||
order_info, event_settings_widget, oauth_application_registered, order_position_buttons
|
||||
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
|
||||
@@ -23,7 +23,7 @@ that we'll provide in this plugin::
|
||||
|
||||
|
||||
@receiver(register_invoice_renderers, dispatch_uid="output_custom")
|
||||
def register_infoice_renderers(sender, **kwargs):
|
||||
def register_invoice_renderers(sender, **kwargs):
|
||||
from .invoice import MyInvoiceRenderer
|
||||
return MyInvoiceRenderer
|
||||
|
||||
|
||||
@@ -79,6 +79,9 @@ human-readable error messages. We recommend using the ``django.utils.functional.
|
||||
decorator, as it might get called a lot. You can also implement ``compatibility_warnings``,
|
||||
those will be displayed but not block the plugin execution.
|
||||
|
||||
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
|
||||
is available for a specific event. If not, it will not be shown in the plugin list of that event.
|
||||
|
||||
Plugin registration
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ api
|
||||
auditability
|
||||
auth
|
||||
autobuild
|
||||
availabilities
|
||||
backend
|
||||
backends
|
||||
banktransfer
|
||||
|
||||
@@ -34,4 +34,5 @@ git push
|
||||
# Unlock Weblate
|
||||
for c in $COMPONENTS; do
|
||||
wlc unlock $c;
|
||||
wlc pull $c;
|
||||
done
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.3.0"
|
||||
__version__ = "2.4.0"
|
||||
|
||||
@@ -95,7 +95,7 @@ class EventSerializer(I18nAwareModelSerializer):
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
plugins_available = {
|
||||
p.module for p in get_all_plugins()
|
||||
p.module for p in get_all_plugins(self.instance)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'position', 'picture', 'available_from', 'available_until',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
|
||||
'variations', 'addons', 'original_price', 'require_approval')
|
||||
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
|
||||
read_only_fields = ('has_variations', 'picture')
|
||||
|
||||
def get_serializer_context(self):
|
||||
|
||||
@@ -114,9 +114,7 @@ class PositionDownloadsField(serializers.Field):
|
||||
if instance.order.status != Order.STATUS_PAID:
|
||||
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
|
||||
return []
|
||||
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
|
||||
return []
|
||||
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
|
||||
if not instance.generate_ticket:
|
||||
return []
|
||||
|
||||
request = self.context['request']
|
||||
@@ -324,7 +322,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.objects.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
raise ValidationError(
|
||||
'You cannot assign a position secret that already exists.'
|
||||
)
|
||||
@@ -557,7 +555,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
|
||||
payment_date=now()
|
||||
)
|
||||
elif payment_provider == "free" and order.total != Decimal('0.00'):
|
||||
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Q, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -258,9 +258,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
except CheckInError as e:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code
|
||||
'reason': e.code,
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
}, status=201)
|
||||
|
||||
def get_object(self):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
if self.kwargs['pk'].isnumeric():
|
||||
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
|
||||
else:
|
||||
obj = get_object_or_404(queryset, secret=self.kwargs['pk'])
|
||||
return obj
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
import pytz
|
||||
@@ -186,6 +187,12 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
@detail_route(methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
try:
|
||||
cancellation_fee = float(Decimal(cancellation_fee))
|
||||
except:
|
||||
cancellation_fee = None
|
||||
|
||||
order = self.get_object()
|
||||
if not order.cancel_allowed():
|
||||
@@ -194,14 +201,21 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail
|
||||
)
|
||||
try:
|
||||
cancel_order(
|
||||
order,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
|
||||
device=request.auth if isinstance(request.auth, Device) else None,
|
||||
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
|
||||
send_mail=send_mail,
|
||||
cancellation_fee=cancellation_fee
|
||||
)
|
||||
except OrderError as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -442,10 +456,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
|
||||
if pos.order.status != Order.STATUS_PAID:
|
||||
raise PermissionDenied("Downloads are not available for unpaid orders.")
|
||||
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
|
||||
raise PermissionDenied("Downloads are not enabled for add-on products.")
|
||||
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
|
||||
raise PermissionDenied("Downloads are not enabled for non-admission products.")
|
||||
if not pos.generate_ticket:
|
||||
raise PermissionDenied("Downloads are not enabled for this product.")
|
||||
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=provider.identifier, file__isnull=False
|
||||
@@ -515,7 +527,10 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
|
||||
request.data.get('amount', str(payment.amount))
|
||||
)
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
|
||||
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
|
||||
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -624,10 +639,14 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
|
||||
if request.data.get('mark_refunded', False):
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.get('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.get('mark_canceled', False)
|
||||
if mark_refunded:
|
||||
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
|
||||
auth=self.request.auth)
|
||||
else:
|
||||
elif not (refund.order.status == Order.STATUS_PAID and refund.order.pending_sum <= 0):
|
||||
refund.order.status = Order.STATUS_PENDING
|
||||
refund.order.set_expires(
|
||||
now(),
|
||||
@@ -653,7 +672,10 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return ctx
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
if 'mark_refunded' in request.data:
|
||||
mark_refunded = request.data.pop('mark_refunded', False)
|
||||
else:
|
||||
mark_refunded = request.data.pop('mark_canceled', False)
|
||||
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -770,7 +792,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
raise PermissionDenied('The invoice file is no longer stored on the server.')
|
||||
else:
|
||||
c = generate_cancellation(inv)
|
||||
if inv.order.status not in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(inv.order)
|
||||
else:
|
||||
inv = c
|
||||
|
||||
@@ -124,12 +124,12 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i in serializer.instance:
|
||||
i.log_action(
|
||||
for i, v in enumerate(serializer.instance):
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
data=self.request.data[i]
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@@ -144,10 +144,6 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.refund.created.externally',
|
||||
_('External refund of payment'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.approved',
|
||||
_('Order approved'),
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Tuple
|
||||
|
||||
from defusedcsv import csv
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext, ugettext_lazy as _
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.cell.cell import KNOWN_TYPES
|
||||
|
||||
@@ -143,3 +143,73 @@ class ListExporter(BaseExporter):
|
||||
return self._render_csv(form_data, dialect='excel')
|
||||
elif form_data.get('_format') == 'semicolon':
|
||||
return self._render_csv(form_data, dialect='excel', delimiter=';')
|
||||
|
||||
|
||||
class MultiSheetListExporter(ListExporter):
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def export_form_fields(self) -> dict:
|
||||
choices = [
|
||||
('xlsx', _('Combined Excel (.xlsx)')),
|
||||
]
|
||||
for s, l in self.sheets:
|
||||
choices += [
|
||||
(s + ':default', str(l) + ' – ' + ugettext('CSV (with commas)')),
|
||||
(s + ':excel', str(l) + ' – ' + ugettext('CSV (Excel-style)')),
|
||||
(s + ':semicolon', str(l) + ' – ' + ugettext('CSV (with semicolons)')),
|
||||
]
|
||||
ff = OrderedDict(
|
||||
[
|
||||
('_format',
|
||||
forms.ChoiceField(
|
||||
label=_('Export format'),
|
||||
choices=choices,
|
||||
)),
|
||||
]
|
||||
)
|
||||
ff.update(self.additional_form_fields)
|
||||
return ff
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
pass
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def _render_sheet_csv(self, form_data, sheet, **kwargs):
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, **kwargs)
|
||||
for line in self.iterate_sheet(form_data, sheet):
|
||||
writer.writerow(line)
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data):
|
||||
wb = Workbook()
|
||||
ws = wb.get_active_sheet()
|
||||
wb.remove(ws)
|
||||
for s, l in self.sheets:
|
||||
ws = wb.create_sheet(str(l))
|
||||
for i, line in enumerate(self.iterate_sheet(form_data, sheet=s)):
|
||||
for j, val in enumerate(line):
|
||||
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
|
||||
wb.save(f.name)
|
||||
f.seek(0)
|
||||
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
|
||||
|
||||
def render(self, form_data: dict) -> Tuple[str, str, bytes]:
|
||||
if form_data.get('_format') == 'xlsx':
|
||||
return self._render_xlsx(form_data)
|
||||
elif ':' in form_data.get('_format'):
|
||||
sheet, f = form_data.get('_format').split(':')
|
||||
if f == 'default':
|
||||
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
|
||||
elif f == 'csv-excel':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel')
|
||||
elif f == 'semicolon':
|
||||
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')
|
||||
|
||||
@@ -10,7 +10,7 @@ from ..signals import register_data_exporters
|
||||
|
||||
class JSONExporter(BaseExporter):
|
||||
identifier = 'json'
|
||||
verbose_name = 'JSON'
|
||||
verbose_name = 'Order data (JSON)'
|
||||
|
||||
def render(self, form_data):
|
||||
jo = {
|
||||
|
||||
@@ -3,22 +3,32 @@ from decimal import Decimal
|
||||
|
||||
import pytz
|
||||
from django import forms
|
||||
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
|
||||
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import localize
|
||||
from django.utils.formats import date_format, localize
|
||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
InvoiceAddress, InvoiceLine, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import ListExporter
|
||||
from ..exporter import ListExporter, MultiSheetListExporter
|
||||
from ..signals import register_data_exporters
|
||||
|
||||
|
||||
class OrderListExporter(ListExporter):
|
||||
class OrderListExporter(MultiSheetListExporter):
|
||||
identifier = 'orderlist'
|
||||
verbose_name = ugettext_lazy('List of orders')
|
||||
verbose_name = ugettext_lazy('Order data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('orders', _('Orders')),
|
||||
('positions', _('Order positions')),
|
||||
('fees', _('Order fees')),
|
||||
)
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -49,7 +59,15 @@ class OrderListExporter(ListExporter):
|
||||
tax_rates = sorted(tax_rates)
|
||||
return tax_rates
|
||||
|
||||
def iterate_list(self, form_data: dict):
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'orders':
|
||||
return self.iterate_orders(form_data)
|
||||
elif sheet == 'positions':
|
||||
return self.iterate_positions(form_data)
|
||||
elif sheet == 'fees':
|
||||
return self.iterate_fees(form_data)
|
||||
|
||||
def iterate_orders(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
p_date = OrderPayment.objects.filter(
|
||||
@@ -160,13 +178,188 @@ class OrderListExporter(ListExporter):
|
||||
row.append(', '.join([i.number for i in order.invoices.all()]))
|
||||
yield row
|
||||
|
||||
def iterate_fees(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderFee.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related('order', 'order__invoice_address', 'tax_rule')
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Fee type'),
|
||||
_('Description'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
op.get_fee_type_display(),
|
||||
op.description,
|
||||
op.value,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
]
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def iterate_positions(self, form_data: dict):
|
||||
tz = pytz.timezone(self.event.settings.timezone)
|
||||
|
||||
qs = OrderPosition.objects.filter(
|
||||
order__event=self.event,
|
||||
).select_related(
|
||||
'order', 'order__invoice_address', 'item', 'variation',
|
||||
'voucher', 'tax_rule'
|
||||
).prefetch_related(
|
||||
'answers', 'answers__question'
|
||||
)
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID)
|
||||
|
||||
headers = [
|
||||
_('Order code'),
|
||||
_('Position ID'),
|
||||
_('Status'),
|
||||
_('Email'),
|
||||
_('Order date'),
|
||||
_('Product'),
|
||||
_('Variation'),
|
||||
_('Price'),
|
||||
_('Tax rate'),
|
||||
_('Tax rule'),
|
||||
_('Tax value'),
|
||||
_('Attendee name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
]
|
||||
questions = list(self.event.questions.all())
|
||||
for q in questions:
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(_('Invoice address name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
]
|
||||
|
||||
yield headers
|
||||
|
||||
for op in qs.order_by('order__datetime', 'positionid'):
|
||||
order = op.order
|
||||
row = [
|
||||
order.code,
|
||||
op.positionid,
|
||||
order.get_status_display(),
|
||||
order.email,
|
||||
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
|
||||
str(op.item),
|
||||
str(op.variation) if op.variation else '',
|
||||
op.price,
|
||||
op.tax_rate,
|
||||
str(op.tax_rule) if op.tax_rule else '',
|
||||
op.tax_value,
|
||||
op.attendee_name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
op.attendee_name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
op.attendee_email,
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
]
|
||||
acache = {}
|
||||
for a in op.answers.all():
|
||||
acache[a.question_id] = str(a)
|
||||
for q in questions:
|
||||
row.append(acache.get(q.pk, ''))
|
||||
try:
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
|
||||
class PaymentListExporter(ListExporter):
|
||||
identifier = 'paymentlist'
|
||||
verbose_name = ugettext_lazy('List of payments and refunds')
|
||||
verbose_name = ugettext_lazy('Order payments and refunds')
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
@@ -208,7 +401,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
headers = [
|
||||
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Amount'), _('Payment method')
|
||||
_('Status code'), _('Amount'), _('Payment method')
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -225,6 +418,7 @@ class PaymentListExporter(ListExporter):
|
||||
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
|
||||
d2,
|
||||
obj.get_state_display(),
|
||||
obj.state,
|
||||
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
|
||||
provider_names.get(obj.provider, obj.provider)
|
||||
]
|
||||
@@ -263,6 +457,174 @@ class QuotaListExporter(ListExporter):
|
||||
return '{}_quotas'.format(self.event.slug)
|
||||
|
||||
|
||||
class InvoiceDataExporter(MultiSheetListExporter):
|
||||
identifier = 'invoicedata'
|
||||
verbose_name = ugettext_lazy('Invoice data')
|
||||
|
||||
@property
|
||||
def sheets(self):
|
||||
return (
|
||||
('invoices', _('Invoices')),
|
||||
('lines', _('Invoice lines')),
|
||||
)
|
||||
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
if sheet == 'invoices':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Language'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
_('Reverse charge'),
|
||||
_('Shown foreign currency'),
|
||||
_('Foreign currency rate'),
|
||||
_('Total value (with taxes)'),
|
||||
_('Total value (without taxes)'),
|
||||
]
|
||||
qs = self.event.invoices.order_by('full_invoice_no').select_related(
|
||||
'order', 'refers'
|
||||
).annotate(
|
||||
total_gross=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum('gross_value')
|
||||
).values('s')
|
||||
),
|
||||
total_net=Subquery(
|
||||
InvoiceLine.objects.filter(
|
||||
invoice=OuterRef('pk')
|
||||
).order_by().values('invoice').annotate(
|
||||
s=Sum(F('gross_value') - F('tax_value'))
|
||||
).values('s')
|
||||
)
|
||||
)
|
||||
for i in qs:
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.locale,
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
_('Yes') if i.reverse_charge else _('No'),
|
||||
i.foreign_currency_display,
|
||||
i.foreign_currency_rate,
|
||||
i.total_gross if i.total_gross else Decimal('0.00'),
|
||||
Decimal(i.total_net if i.total_net else '0.00').quantize(Decimal('0.01')),
|
||||
]
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
_('Line number'),
|
||||
_('Description'),
|
||||
_('Gross price'),
|
||||
_('Net price'),
|
||||
_('Tax value'),
|
||||
_('Tax rate'),
|
||||
_('Tax name'),
|
||||
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
_('E-mail address'),
|
||||
_('Invoice type'),
|
||||
_('Cancellation of'),
|
||||
_('Invoice sender:') + ' ' + _('Name'),
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Company'),
|
||||
_('Invoice recipient:') + ' ' + _('Name'),
|
||||
_('Invoice recipient:') + ' ' + _('Street address'),
|
||||
_('Invoice recipient:') + ' ' + _('ZIP code'),
|
||||
_('Invoice recipient:') + ' ' + _('City'),
|
||||
_('Invoice recipient:') + ' ' + _('Country'),
|
||||
_('Invoice recipient:') + ' ' + _('VAT ID'),
|
||||
_('Invoice recipient:') + ' ' + _('Beneficiary'),
|
||||
_('Invoice recipient:') + ' ' + _('Internal reference'),
|
||||
]
|
||||
qs = InvoiceLine.objects.filter(
|
||||
invoice__event=self.event
|
||||
).order_by('invoice__full_invoice_no', 'position').select_related(
|
||||
'invoice', 'invoice__order', 'invoice__refers'
|
||||
)
|
||||
for l in qs:
|
||||
i = l.invoice
|
||||
yield [
|
||||
i.full_invoice_no,
|
||||
l.position + 1,
|
||||
l.description.replace("<br />", " - "),
|
||||
l.gross_value,
|
||||
l.net_value,
|
||||
l.tax_value,
|
||||
l.tax_rate,
|
||||
l.tax_name,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
i.order.email,
|
||||
_('Cancellation') if i.is_cancellation else _('Invoice'),
|
||||
i.refers.full_invoice_no if i.refers else '',
|
||||
i.invoice_from_name,
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
i.invoice_to_company,
|
||||
i.invoice_to_name,
|
||||
i.invoice_to_street or i.invoice_to,
|
||||
i.invoice_to_zipcode,
|
||||
i.invoice_to_city,
|
||||
i.invoice_to_country,
|
||||
i.invoice_to_vat_id,
|
||||
i.invoice_to_beneficiary,
|
||||
i.internal_reference,
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_orderlist")
|
||||
def register_orderlist_exporter(sender, **kwargs):
|
||||
return OrderListExporter
|
||||
@@ -276,3 +638,8 @@ def register_paymentlist_exporter(sender, **kwargs):
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
|
||||
def register_quotalist_exporter(sender, **kwargs):
|
||||
return QuotaListExporter
|
||||
|
||||
|
||||
@receiver(register_data_exporters, dispatch_uid="exporter_invoicedata")
|
||||
def register_invoicedata_exporter(sender, **kwargs):
|
||||
return InvoiceDataExporter
|
||||
|
||||
@@ -120,7 +120,7 @@ class RegistrationForm(forms.Form):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(email=email).exists():
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_email'],
|
||||
code='duplicate_email'
|
||||
|
||||
@@ -275,10 +275,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
'internal_reference', 'beneficiary')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'beneficiary': forms.Textarea(attrs={'rows': 3}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
@@ -291,17 +292,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.event = event = kwargs.pop('event')
|
||||
self.request = kwargs.pop('request', None)
|
||||
self.validate_vat_id = kwargs.pop('validate_vat_id')
|
||||
self.all_optional = kwargs.pop('all_optional', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
|
||||
if not event.settings.invoice_address_required:
|
||||
if not event.settings.invoice_address_required or self.all_optional:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
elif event.settings.invoice_address_company_required:
|
||||
elif event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.initial['is_business'] = True
|
||||
|
||||
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
|
||||
@@ -314,16 +316,19 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.invoice_name_required,
|
||||
required=event.settings.invoice_name_required and not self.all_optional,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Name'),
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
|
||||
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
|
||||
if not event.settings.invoice_address_beneficiary:
|
||||
del self.fields['beneficiary']
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('is_business'):
|
||||
|
||||
@@ -69,7 +69,7 @@ class UserSettingsForm(forms.ModelForm):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
|
||||
@@ -4,6 +4,7 @@ from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from typing import Tuple
|
||||
|
||||
import bleach
|
||||
import vat_moss.exchange_rates
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
@@ -31,6 +32,31 @@ from pretix.base.templatetags.money import money_filter
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NumberedCanvas(Canvas):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.font_regular = kwargs.pop('font_regular')
|
||||
super().__init__(*args, **kwargs)
|
||||
self._saved_page_states = []
|
||||
|
||||
def showPage(self):
|
||||
self._saved_page_states.append(dict(self.__dict__))
|
||||
self._startPage()
|
||||
|
||||
def save(self):
|
||||
num_pages = len(self._saved_page_states)
|
||||
for state in self._saved_page_states:
|
||||
self.__dict__.update(state)
|
||||
self.draw_page_number(num_pages)
|
||||
Canvas.showPage(self)
|
||||
Canvas.save(self)
|
||||
|
||||
def draw_page_number(self, page_count):
|
||||
self.saveState()
|
||||
self.setFont(self.font_regular, 8)
|
||||
self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,))
|
||||
self.restoreState()
|
||||
|
||||
|
||||
class BaseInvoiceRenderer:
|
||||
"""
|
||||
This is the base class for all invoice renderers.
|
||||
@@ -79,6 +105,9 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
top_margin = 20 * mm
|
||||
bottom_margin = 15 * mm
|
||||
doc_template_class = BaseDocTemplate
|
||||
canvas_class = Canvas
|
||||
font_regular = 'OpenSans'
|
||||
font_bold = 'OpenSansBd'
|
||||
|
||||
def _init(self):
|
||||
"""
|
||||
@@ -92,10 +121,10 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles.
|
||||
"""
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName='OpenSansBd', fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName='OpenSans', fontSize=8, leading=10))
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
|
||||
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
|
||||
return stylesheet
|
||||
|
||||
def _register_fonts(self):
|
||||
@@ -171,7 +200,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
)
|
||||
])
|
||||
story = self._get_story(doc)
|
||||
doc.build(story)
|
||||
doc.build(story, canvasmaker=self.canvas_class)
|
||||
return doc
|
||||
|
||||
def generate(self, invoice: Invoice):
|
||||
@@ -206,10 +235,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)')
|
||||
|
||||
def canvas_class(self, *args, **kwargs):
|
||||
kwargs['font_regular'] = self.font_regular
|
||||
return NumberedCanvas(*args, **kwargs)
|
||||
|
||||
def _on_other_page(self, canvas: Canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
@@ -233,72 +265,71 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(self.pagesize[0] - 20 * mm, 10 * mm, pgettext("invoice", "Page %d") % (doc.page,))
|
||||
canvas.setFont(self.font_regular, 8)
|
||||
|
||||
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
|
||||
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_from(canvas)
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
self._draw_invoice_to(canvas)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.order.full_code)
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(self.invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if self.invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.setFont(self.font_regular, 10)
|
||||
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
@@ -349,7 +380,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.setFont(self.font_bold, 8)
|
||||
textobject.textLine(pgettext('invoice', 'Event').upper())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
@@ -390,6 +421,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_beneficiary:
|
||||
story.append(Paragraph(
|
||||
pgettext('invoice', 'Beneficiary') + ':<br />' +
|
||||
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.introductory_text:
|
||||
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
@@ -400,8 +438,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('FONTNAME', (0, 0), (-1, 0), self.font_bold),
|
||||
('FONTNAME', (0, -1), (-1, -1), self.font_bold),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
@@ -469,7 +507,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
('FONTNAME', (0, 0), (-1, -1), 'OpenSans'),
|
||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||
]
|
||||
thead = [
|
||||
pgettext('invoice', 'Tax rate'),
|
||||
|
||||
@@ -189,6 +189,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'connect-src': ["{dynamic}", "{media}", "https://checkout.stripe.com"],
|
||||
'img-src': ["{static}", "{media}", "data:", "https://*.stripe.com"],
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
# single-sign-on this can be nearly anything so we cannot really restrict
|
||||
|
||||
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
62
src/pretix/base/migrations/0104_auto_20181114_1526.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-14 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.manager
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def change_refunded_to_canceled(apps, schema_editor):
|
||||
Order = apps.get_model('pretixbase', 'Order')
|
||||
Order.objects.filter(status='r').update(status='c')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0103_auto_20181121_1224'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelManagers(
|
||||
name='orderposition',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='fee_type',
|
||||
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('cancellation', 'Cancellation fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_positions',
|
||||
to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='orderfee',
|
||||
managers=[
|
||||
('all', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderfee',
|
||||
name='canceled',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='all_fees', to='pretixbase.Order', verbose_name='Order'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
change_refunded_to_canceled, migrations.RunPython.noop
|
||||
)
|
||||
]
|
||||
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
27
src/pretix/base/migrations/0105_auto_20190112_1512.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1 on 2019-01-12 15:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-04 13:02
|
||||
|
||||
import django.db.migrations.operations.special
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [('pretixbase', '0105_auto_20190112_1512'), ('pretixbase', '0106_auto_20190118_1527'),
|
||||
('pretixbase', '0107_auto_20190129_1337')]
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0104_auto_20181114_1526'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='beneficiary',
|
||||
field=models.TextField(blank=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_beneficiary',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='Beneficiary'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=enable_notifications_for_everyone,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
31
src/pretix/base/migrations/0106_auto_20190118_1527.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-18 15:27
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def enable_notifications_for_everyone(apps, schema_editor):
|
||||
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
|
||||
User = apps.get_model('pretixbase', 'User')
|
||||
create = []
|
||||
for u in User.objects.iterator():
|
||||
create.append(NotificationSetting(
|
||||
user=u,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
))
|
||||
if len(create) > 200:
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
NotificationSetting.objects.bulk_create(create)
|
||||
create.clear()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0105_auto_20190112_1512'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop)
|
||||
]
|
||||
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
22
src/pretix/base/migrations/0107_auto_20190129_1337.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-01-29 13:37
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0106_auto_20190118_1527'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='datetime',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
22
src/pretix/base/migrations/0108_auto_20190201_1527.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-01 15:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import jsonfallback.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0107_auto_20190129_1337'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='generate_tickets',
|
||||
field=models.NullBooleanField(verbose_name='Allow ticket download'),
|
||||
),
|
||||
]
|
||||
@@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower()
|
||||
is_new = not self.pk
|
||||
super().save(*args, **kwargs)
|
||||
if is_new:
|
||||
self.notification_settings.create(
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
event=None,
|
||||
method='mail',
|
||||
enabled=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
@@ -76,8 +76,10 @@ class LoggingMixin:
|
||||
kwargs['api_token'] = api_token
|
||||
|
||||
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, **kwargs)
|
||||
if data:
|
||||
if isinstance(data, dict):
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder)
|
||||
elif data:
|
||||
raise TypeError("You should only supply dictionaries as log data.")
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ class CheckinList(LoggedModel):
|
||||
'order have not been paid. This only works with pretixdesk '
|
||||
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
|
||||
|
||||
class Meta:
|
||||
ordering = ('subevent__date_from', 'name')
|
||||
|
||||
@staticmethod
|
||||
def annotate_with_numbers(qs, event):
|
||||
"""
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.core.files.storage import default_storage
|
||||
from django.core.mail import get_connection
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, OuterRef, Prefetch, Q, Subquery
|
||||
from django.template.defaultfilters import date as _date
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
@@ -22,6 +22,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.validators import EventSlugBlacklistValidator
|
||||
from pretix.helpers.database import GroupConcat
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.helpers.json import safe_string
|
||||
|
||||
@@ -159,6 +160,79 @@ class EventMixin:
|
||||
|
||||
return safe_string(json.dumps(eventdict))
|
||||
|
||||
@classmethod
|
||||
def annotated(cls, qs, channel='web'):
|
||||
from pretix.base.models import Item, ItemVariation, Quota
|
||||
|
||||
sq_active_item = Item.objects.filter_available(channel=channel).filter(
|
||||
Q(variations__isnull=True)
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
sq_active_variation = ItemVariation.objects.filter(
|
||||
Q(active=True)
|
||||
& Q(item__active=True)
|
||||
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
|
||||
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
|
||||
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
|
||||
& Q(item__sales_channels__contains=channel)
|
||||
& Q(item__hide_without_voucher=False) # TODO: does this make sense?
|
||||
& Q(quotas__pk=OuterRef('pk'))
|
||||
).order_by().values_list('quotas__pk').annotate(
|
||||
items=GroupConcat('pk', delimiter=',')
|
||||
).values('items')
|
||||
return qs.prefetch_related(
|
||||
Prefetch(
|
||||
'quotas',
|
||||
to_attr='active_quotas',
|
||||
queryset=Quota.objects.annotate(
|
||||
active_items=Subquery(sq_active_item, output_field=models.TextField()),
|
||||
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
|
||||
).exclude(
|
||||
Q(active_items="") & Q(active_variations="")
|
||||
).select_related('event', 'subevent')
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def best_availability_state(self):
|
||||
from .items import Quota
|
||||
|
||||
if not hasattr(self, 'active_quotas'):
|
||||
raise TypeError("Call this only if you fetched the subevents via Event/SubEvent.annotated()")
|
||||
items_available = set()
|
||||
vars_available = set()
|
||||
items_reserved = set()
|
||||
vars_reserved = set()
|
||||
items_gone = set()
|
||||
vars_gone = set()
|
||||
for q in self.active_quotas:
|
||||
res = q.availability(allow_cache=True)
|
||||
|
||||
if res[0] == Quota.AVAILABILITY_OK:
|
||||
if q.active_items:
|
||||
items_available.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] == Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_reserved.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_available.update(q.active_variations.split(","))
|
||||
elif res[0] < Quota.AVAILABILITY_RESERVED:
|
||||
if q.active_items:
|
||||
items_gone.update(q.active_items.split(","))
|
||||
if q.active_variations:
|
||||
vars_gone.update(q.active_variations.split(","))
|
||||
if not self.active_quotas:
|
||||
return None
|
||||
if items_available - items_reserved - items_gone or vars_available - vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_OK
|
||||
if items_reserved - items_gone or vars_reserved - vars_gone:
|
||||
return Quota.AVAILABILITY_RESERVED
|
||||
return Quota.AVAILABILITY_GONE
|
||||
|
||||
|
||||
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
|
||||
class Event(EventMixin, LoggedModel):
|
||||
@@ -281,10 +355,11 @@ class Event(EventMixin, LoggedModel):
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
OrderPosition.objects.filter(order__event=self).delete()
|
||||
OrderPosition.all.filter(order__event=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order__event=self).delete()
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -468,6 +543,7 @@ class Event(EventMixin, LoggedModel):
|
||||
else:
|
||||
s.save()
|
||||
|
||||
self.settings.flush()
|
||||
event_copy_data.send(
|
||||
sender=self, other=other,
|
||||
tax_map=tax_map, category_map=category_map, item_map=item_map, variation_map=variation_map,
|
||||
@@ -572,8 +648,10 @@ class Event(EventMixin, LoggedModel):
|
||||
)
|
||||
).order_by('date_from', 'name')
|
||||
|
||||
@property
|
||||
def subevent_list_subevents(self):
|
||||
def subevents_annotated(self, channel):
|
||||
return SubEvent.annotated(self.subevents, channel)
|
||||
|
||||
def subevents_sorted(self, queryset):
|
||||
ordering = self.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||
orderfields = {
|
||||
'date_ascending': ('date_from', 'name'),
|
||||
@@ -581,7 +659,7 @@ class Event(EventMixin, LoggedModel):
|
||||
'name_ascending': ('name', 'date_from'),
|
||||
'name_descending': ('-name', 'date_from'),
|
||||
}[ordering]
|
||||
subevs = self.subevents.filter(
|
||||
subevs = queryset.filter(
|
||||
Q(active=True) & (
|
||||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
|
||||
| Q(date_to__gte=now())
|
||||
@@ -674,6 +752,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return not self.orders.exists() and not self.invoices.exists()
|
||||
|
||||
def delete_sub_objects(self):
|
||||
self.cartposition_set.all().delete()
|
||||
self.items.all().delete()
|
||||
self.subevents.all().delete()
|
||||
|
||||
@@ -682,7 +761,7 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
plugins_active = self.get_plugins()
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins()
|
||||
p.module: p for p in get_all_plugins(self)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class Invoice(models.Model):
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
invoice_to_beneficiary = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
introductory_text = models.TextField(blank=True)
|
||||
@@ -191,6 +192,9 @@ class Invoice(models.Model):
|
||||
unique_together = ('organizer', 'prefix', 'invoice_no')
|
||||
ordering = ('date', 'invoice_no',)
|
||||
|
||||
def __repr__(self):
|
||||
return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
"""
|
||||
|
||||
@@ -153,6 +153,30 @@ class SubEventItemVariation(models.Model):
|
||||
self.subevent.event.cache.clear()
|
||||
|
||||
|
||||
class ItemQuerySet(models.QuerySet):
|
||||
def filter_available(self, channel='web', voucher=None, allow_addons=False):
|
||||
q = (
|
||||
# IMPORTANT: If this is updated, also update the ItemVariation query
|
||||
# in models/event.py: EventMixin.annotated()
|
||||
Q(active=True)
|
||||
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
|
||||
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
|
||||
& Q(sales_channels__contains=channel)
|
||||
)
|
||||
if not allow_addons:
|
||||
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
|
||||
qs = self.filter(q)
|
||||
|
||||
vouchq = Q(hide_without_voucher=False)
|
||||
if voucher:
|
||||
if voucher.item_id:
|
||||
vouchq |= Q(pk=voucher.item_id)
|
||||
qs = qs.filter(pk=voucher.item_id)
|
||||
elif voucher.quota_id:
|
||||
qs = qs.filter(quotas__in=[voucher.quota_id])
|
||||
return qs.filter(vouchq)
|
||||
|
||||
|
||||
class Item(LoggedModel):
|
||||
"""
|
||||
An item is a thing which can be sold. It belongs to an event and may or may not belong to a category.
|
||||
@@ -200,6 +224,8 @@ class Item(LoggedModel):
|
||||
:type sales_channels: bool
|
||||
"""
|
||||
|
||||
objects = ItemQuerySet.as_manager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.PROTECT,
|
||||
@@ -261,6 +287,10 @@ class Item(LoggedModel):
|
||||
),
|
||||
default=False
|
||||
)
|
||||
generate_tickets = models.NullBooleanField(
|
||||
verbose_name=_("Generate tickets"),
|
||||
blank=True, null=True,
|
||||
)
|
||||
position = models.IntegerField(
|
||||
default=0
|
||||
)
|
||||
@@ -301,9 +331,8 @@ class Item(LoggedModel):
|
||||
allow_cancel = models.BooleanField(
|
||||
verbose_name=_('Allow product to be canceled'),
|
||||
default=True,
|
||||
help_text=_('If this is active and the general event settings allow it, orders containing this product can be '
|
||||
'canceled by the user until the order is paid for. Users cannot cancel paid orders on their own '
|
||||
'and you can cancel orders at all times, regardless of this setting')
|
||||
help_text=_('If this is checked, the usual cancellation settings of this event apply. If this is unchecked, '
|
||||
'orders containing this product can not be canceled by users but only by you.')
|
||||
)
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
@@ -416,7 +445,7 @@ class Item(LoggedModel):
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return not OrderPosition.objects.filter(item=self).exists()
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
@@ -930,6 +959,16 @@ class Quota(LoggedModel):
|
||||
:type size: int
|
||||
:param items: The set of :py:class:`Item` objects this quota applies to
|
||||
:param variations: The set of :py:class:`ItemVariation` objects this quota applies to
|
||||
|
||||
This model keeps a cache of the quota availability that is used in places where up-to-date
|
||||
data is not important. This cache might be out of date even though a more recent quota was
|
||||
calculated. This is intentional to keep database writes low. Currently, the cached values
|
||||
are written whenever the quota is being calculated throughout the system and the cache is
|
||||
at least 120 seconds old or if the new value is qualitatively "better" than the cached one
|
||||
(i.e. more free quota).
|
||||
|
||||
There's also a cronjob that refreshes the cache of every quota if there is any log entry in
|
||||
the event that is newer than the quota's cached time.
|
||||
"""
|
||||
|
||||
AVAILABILITY_GONE = 0
|
||||
@@ -1012,6 +1051,15 @@ class Quota(LoggedModel):
|
||||
This method is used to determine whether Items or ItemVariations belonging
|
||||
to this quota should currently be available for sale.
|
||||
|
||||
:param count_waitinglist: Whether or not take waiting list reservations into account. Defaults
|
||||
to ``True``.
|
||||
:param _cache: A dictionary mapping quota IDs to availabilities. If this quota is already
|
||||
contained in that dictionary, this value will be used. Otherwise, the dict
|
||||
will be populated accordingly.
|
||||
:param allow_cache: Allow for values to be returned from the longer-term cache, see also
|
||||
the documentation of this model class. Only works if ``count_waitinglist`` is
|
||||
set to ``True``.
|
||||
|
||||
:returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants
|
||||
and the second is the number of available tickets.
|
||||
"""
|
||||
@@ -1027,7 +1075,10 @@ class Quota(LoggedModel):
|
||||
res = self._availability(now_dt, count_waitinglist)
|
||||
|
||||
self.event.cache.delete('item_quota_cache')
|
||||
if count_waitinglist and not self.cache_is_hot(now_dt):
|
||||
rewrite_cache = count_waitinglist and (
|
||||
not self.cache_is_hot(now_dt) or res[0] > self.cached_availability_state
|
||||
)
|
||||
if rewrite_cache:
|
||||
self.cached_availability_state = res[0]
|
||||
self.cached_availability_number = res[1]
|
||||
self.cached_availability_time = now_dt
|
||||
@@ -1064,16 +1115,16 @@ class Quota(LoggedModel):
|
||||
|
||||
size_left -= self.count_blocking_vouchers(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
if count_waitinglist:
|
||||
size_left -= self.count_waiting_list_pending()
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
return Quota.AVAILABILITY_ORDERED, 0
|
||||
|
||||
size_left -= self.count_in_cart(now_dt)
|
||||
if size_left <= 0:
|
||||
return Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
return Quota.AVAILABILITY_OK, size_left
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
@@ -70,7 +71,6 @@ class Order(LockModel, LoggedModel):
|
||||
* ``STATUS_PAID``
|
||||
* ``STATUS_EXPIRED``
|
||||
* ``STATUS_CANCELED``
|
||||
* ``STATUS_REFUNDED``
|
||||
|
||||
:param event: The event this order belongs to
|
||||
:type event: Event
|
||||
@@ -102,13 +102,12 @@ class Order(LockModel, LoggedModel):
|
||||
STATUS_PAID = "p"
|
||||
STATUS_EXPIRED = "e"
|
||||
STATUS_CANCELED = "c"
|
||||
STATUS_REFUNDED = "r"
|
||||
STATUS_REFUNDED = "c" # deprecated
|
||||
STATUS_CHOICE = (
|
||||
(STATUS_PENDING, _("pending")),
|
||||
(STATUS_PAID, _("paid")),
|
||||
(STATUS_EXPIRED, _("expired")),
|
||||
(STATUS_CANCELED, _("canceled")),
|
||||
(STATUS_REFUNDED, _("refunded"))
|
||||
)
|
||||
|
||||
code = models.CharField(
|
||||
@@ -138,7 +137,7 @@ class Order(LockModel, LoggedModel):
|
||||
)
|
||||
secret = models.CharField(max_length=32, default=generate_secret)
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date")
|
||||
verbose_name=_("Date"), db_index=True
|
||||
)
|
||||
expires = models.DateTimeField(
|
||||
verbose_name=_("Expiration date")
|
||||
@@ -186,6 +185,28 @@ class Order(LockModel, LoggedModel):
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
|
||||
@property
|
||||
def fees(self):
|
||||
"""
|
||||
Related manager for all non-canceled fees. Use ``all_fees`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_fees(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def count_positions(self):
|
||||
if hasattr(self, 'pcnt'):
|
||||
return self.pcnt or 0
|
||||
return self.positions.count()
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
"""
|
||||
Related manager for all non-canceled positions. Use ``all_positions`` instead if you want
|
||||
canceled positions as well.
|
||||
"""
|
||||
return self.all_positions(manager='objects')
|
||||
|
||||
@cached_property
|
||||
def meta_info_data(self):
|
||||
try:
|
||||
@@ -207,8 +228,8 @@ class Order(LockModel, LoggedModel):
|
||||
@property
|
||||
def pending_sum(self):
|
||||
total = self.total
|
||||
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
total = 0
|
||||
if self.status == Order.STATUS_CANCELED:
|
||||
total = Decimal('0.00')
|
||||
payment_sum = self.payments.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
|
||||
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
|
||||
@@ -219,7 +240,7 @@ class Order(LockModel, LoggedModel):
|
||||
return total - payment_sum + refund_sum
|
||||
|
||||
@classmethod
|
||||
def annotate_overpayments(cls, qs):
|
||||
def annotate_overpayments(cls, qs, results=True, refunds=True, sums=False):
|
||||
payment_sum = OrderPayment.objects.filter(
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
|
||||
order=OuterRef('pk')
|
||||
@@ -237,38 +258,47 @@ class Order(LockModel, LoggedModel):
|
||||
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
|
||||
order=OuterRef('pk')
|
||||
)
|
||||
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
|
||||
if sums:
|
||||
qs = qs.annotate(
|
||||
payment_sum=payment_sum_sq,
|
||||
refund_sum=refund_sum_sq,
|
||||
)
|
||||
|
||||
qs = qs.annotate(
|
||||
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
).annotate(
|
||||
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
|
||||
).annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
|
||||
then=Value('1')),
|
||||
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
pending_sum_t=F('total') - Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
pending_sum_rc=-1 * Coalesce(payment_sum_sq, 0) + Coalesce(refund_sum_sq, 0),
|
||||
)
|
||||
if refunds:
|
||||
qs = qs.annotate(
|
||||
has_external_refund=Exists(external_refund),
|
||||
has_pending_refund=Exists(pending_refund),
|
||||
)
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_pending_with_full_payment=Case(
|
||||
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=1e-8)
|
||||
& Q(require_approval=False),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value('1')),
|
||||
default=Value('0'),
|
||||
output_field=models.IntegerField()
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
@property
|
||||
@@ -336,10 +366,112 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
def cancel_allowed(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING
|
||||
or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00'))
|
||||
self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_deadline(self):
|
||||
if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'):
|
||||
until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
|
||||
else:
|
||||
until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
|
||||
if until:
|
||||
if self.event.has_subevents:
|
||||
return min([
|
||||
until.datetime(se)
|
||||
for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True))
|
||||
])
|
||||
else:
|
||||
return until.datetime(self.event)
|
||||
|
||||
@cached_property
|
||||
def user_cancel_fee(self):
|
||||
fee = Decimal('0.00')
|
||||
if self.event.settings.cancel_allow_user_paid_keep:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep
|
||||
if self.event.settings.cancel_allow_user_paid_keep_percentage:
|
||||
fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total
|
||||
if self.event.settings.cancel_allow_user_paid_keep_fees:
|
||||
fee += self.fees.filter(
|
||||
fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE)
|
||||
).aggregate(
|
||||
s=Sum('value')
|
||||
)['s'] or 0
|
||||
return round_decimal(fee, self.event.currency)
|
||||
|
||||
@property
|
||||
def user_cancel_allowed(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
from .checkin import Checkin
|
||||
|
||||
positions = list(
|
||||
self.positions.all().annotate(
|
||||
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
|
||||
).select_related('item')
|
||||
)
|
||||
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
|
||||
if not cancelable or not positions:
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
elif self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
return False
|
||||
|
||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||
# Algorithm to choose which payments are to be refunded to create the least hassle
|
||||
payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)
|
||||
for p in payments:
|
||||
p.full_refund_possible = p.payment_provider.payment_refund_supported(p)
|
||||
p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p)
|
||||
p.propose_refund = Decimal('0.00')
|
||||
p.available_amount = p.amount - p.refunded_amount
|
||||
|
||||
unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible)
|
||||
to_refund = amount
|
||||
proposals = {}
|
||||
|
||||
while to_refund and unused_payments:
|
||||
bigger = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount > to_refund
|
||||
and p.partial_refund_possible
|
||||
], key=lambda p: p.available_amount)
|
||||
same = [
|
||||
p for p in unused_payments
|
||||
if p.available_amount == to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
]
|
||||
smaller = sorted([
|
||||
p for p in unused_payments
|
||||
if p.available_amount < to_refund
|
||||
and (p.full_refund_possible or p.partial_refund_possible)
|
||||
], key=lambda p: p.available_amount, reverse=True)
|
||||
if same:
|
||||
payment = same[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
elif bigger:
|
||||
payment = bigger[0]
|
||||
proposals[payment] = to_refund
|
||||
to_refund -= to_refund
|
||||
unused_payments.remove(payment)
|
||||
elif smaller:
|
||||
payment = smaller[0]
|
||||
proposals[payment] = payment.available_amount
|
||||
to_refund -= payment.available_amount
|
||||
unused_payments.remove(payment)
|
||||
else:
|
||||
break
|
||||
return proposals
|
||||
|
||||
@staticmethod
|
||||
def normalize_code(code):
|
||||
tr = str.maketrans({
|
||||
@@ -392,15 +524,6 @@ class Order(LockModel, LoggedModel):
|
||||
|
||||
return False # nothing there to modify
|
||||
|
||||
@property
|
||||
def can_user_cancel(self) -> bool:
|
||||
"""
|
||||
Returns whether or not this order can be canceled by the user.
|
||||
"""
|
||||
positions = self.positions.all().select_related('item')
|
||||
cancelable = all([op.item.allow_cancel for op in positions])
|
||||
return self.cancel_allowed() and self.event.settings.cancel_allow_user and cancelable
|
||||
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
return (
|
||||
@@ -563,6 +686,13 @@ class Order(LockModel, LoggedModel):
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def positions_with_tickets(self):
|
||||
for op in self.positions.all():
|
||||
if not op.generate_ticket:
|
||||
continue
|
||||
yield op
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -1254,11 +1384,19 @@ class OrderRefund(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ActivePositionManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(canceled=False)
|
||||
|
||||
|
||||
class OrderFee(models.Model):
|
||||
"""
|
||||
An OrderFee object represents a fee that is added to the order total independently of
|
||||
the actual positions. This might for example be a payment or a shipping fee.
|
||||
|
||||
The default ``OrderFee.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderFee.all`` instead.
|
||||
|
||||
:param value: Gross price of this fee
|
||||
:type value: Decimal
|
||||
:param order: Order this fee is charged with
|
||||
@@ -1275,16 +1413,20 @@ class OrderFee(models.Model):
|
||||
:type tax_rule: TaxRule
|
||||
:param tax_value: The tax amount included in the price
|
||||
:type tax_value: Decimal
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
"""
|
||||
FEE_TYPE_PAYMENT = "payment"
|
||||
FEE_TYPE_SHIPPING = "shipping"
|
||||
FEE_TYPE_SERVICE = "service"
|
||||
FEE_TYPE_CANCELLATION = "cancellation"
|
||||
FEE_TYPE_OTHER = "other"
|
||||
FEE_TYPE_GIFTCARD = "giftcard"
|
||||
FEE_TYPES = (
|
||||
(FEE_TYPE_PAYMENT, _("Payment fee")),
|
||||
(FEE_TYPE_SHIPPING, _("Shipping fee")),
|
||||
(FEE_TYPE_SERVICE, _("Service fee")),
|
||||
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
|
||||
(FEE_TYPE_OTHER, _("Other fees")),
|
||||
(FEE_TYPE_GIFTCARD, _("Gift card")),
|
||||
)
|
||||
@@ -1296,7 +1438,7 @@ class OrderFee(models.Model):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='fees',
|
||||
related_name='all_fees',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
fee_type = models.CharField(
|
||||
@@ -1317,6 +1459,10 @@ class OrderFee(models.Model):
|
||||
max_digits=10, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
@property
|
||||
def net_value(self):
|
||||
@@ -1371,6 +1517,9 @@ class OrderPosition(AbstractPosition):
|
||||
of a specified type (or variation). This has all properties of
|
||||
AbstractPosition.
|
||||
|
||||
The default ``OrderPosition.objects`` manager only contains fees that are not ``canceled``. If
|
||||
you ant all objects, you need to use ``OrderPosition.all`` instead.
|
||||
|
||||
:param order: The order this position is a part of
|
||||
:type order: Order
|
||||
:param positionid: A local ID of this position, counted for each order individually
|
||||
@@ -1383,6 +1532,8 @@ class OrderPosition(AbstractPosition):
|
||||
:type tax_value: Decimal
|
||||
:param secret: The secret used for ticket QR codes
|
||||
:type secret: str
|
||||
:param canceled: True, if this position is canceled and should no longer be regarded
|
||||
:type canceled: bool
|
||||
:param pseudonymization_id: The QR code content for lead scanning
|
||||
:type pseudonymization_id: str
|
||||
"""
|
||||
@@ -1390,7 +1541,7 @@ class OrderPosition(AbstractPosition):
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='positions',
|
||||
related_name='all_positions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
@@ -1412,6 +1563,10 @@ class OrderPosition(AbstractPosition):
|
||||
unique=True,
|
||||
db_index=True
|
||||
)
|
||||
canceled = models.BooleanField(default=False)
|
||||
|
||||
all = models.Manager()
|
||||
objects = ActivePositionManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
@@ -1422,6 +1577,15 @@ class OrderPosition(AbstractPosition):
|
||||
def sort_key(self):
|
||||
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0
|
||||
|
||||
@property
|
||||
def generate_ticket(self):
|
||||
if self.item.generate_tickets is not None:
|
||||
return self.item.generate_tickets
|
||||
return (
|
||||
(self.order.event.settings.ticket_download_addons or not self.addon_to_id) and
|
||||
(self.event.settings.ticket_download_nonadm or self.item.admission)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def transform_cart_positions(cls, cp: List, order) -> list:
|
||||
from . import Voucher
|
||||
@@ -1492,7 +1656,7 @@ class OrderPosition(AbstractPosition):
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
if self.pk is None:
|
||||
while OrderPosition.objects.filter(secret=self.secret).exists():
|
||||
while OrderPosition.all.filter(secret=self.secret).exists():
|
||||
self.secret = generate_position_secret()
|
||||
|
||||
if not self.pseudonymization_id:
|
||||
@@ -1508,7 +1672,7 @@ class OrderPosition(AbstractPosition):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
|
||||
while True:
|
||||
code = get_random_string(length=10, allowed_chars=charset)
|
||||
if not OrderPosition.objects.filter(pseudonymization_id=code).exists():
|
||||
if not OrderPosition.all.filter(pseudonymization_id=code).exists():
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
|
||||
@@ -1596,6 +1760,10 @@ class InvoiceAddress(models.Model):
|
||||
help_text=_('This reference will be printed on your invoice for your convenience.'),
|
||||
blank=True
|
||||
)
|
||||
beneficiary = models.TextField(
|
||||
verbose_name=_('Beneficiary'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
|
||||
@@ -97,7 +97,7 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return (
|
||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not self.event.items.filter(tax_rule=self).exists()
|
||||
and self.event.settings.tax_rate_default != self
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..decimal import round_decimal
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Quota
|
||||
from .orders import Order
|
||||
|
||||
|
||||
def _generate_random_code(prefix=None):
|
||||
@@ -182,7 +183,7 @@ class Voucher(LoggedModel):
|
||||
return self.code
|
||||
|
||||
def allow_delete(self):
|
||||
return self.redeemed == 0
|
||||
return self.redeemed == 0 and not self.orderposition_set.exists()
|
||||
|
||||
def clean(self):
|
||||
Voucher.clean_item_properties(
|
||||
@@ -380,3 +381,11 @@ class Voucher(LoggedModel):
|
||||
return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
return p
|
||||
return original_price
|
||||
|
||||
def distinct_orders(self):
|
||||
"""
|
||||
Return the list of orders where this voucher has been used.
|
||||
Each order will appear at most once.
|
||||
"""
|
||||
|
||||
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
|
||||
|
||||
@@ -159,7 +159,7 @@ class WaitingListEntry(LoggedModel):
|
||||
@staticmethod
|
||||
def clean_duplicate(email, item, variation, subevent, pk):
|
||||
if WaitingListEntry.objects.filter(
|
||||
item=item, variation=variation, email=email, voucher__isnull=True, subevent=subevent
|
||||
item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
|
||||
).exclude(pk=pk).exists():
|
||||
raise ValidationError(_('You are already on this waiting list! We will notify '
|
||||
'you as soon as we have a ticket available for you.'))
|
||||
|
||||
@@ -243,9 +243,9 @@ def register_default_notification_types(sender, **kwargs):
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
_('Order {order.code} has been refunded.')
|
||||
'pretix.event.order.refund.requested',
|
||||
_('Refund requested'),
|
||||
_('You have been requested to issue a refund for {order.code}.')
|
||||
),
|
||||
ActionRequiredNotificationType(
|
||||
sender,
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from PyPDF2 import PdfFileReader
|
||||
from pytz import timezone
|
||||
@@ -192,6 +193,30 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Event organizer info text"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
||||
}),
|
||||
("now_date", {
|
||||
"label": _("Printing date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"SHORT_DATE_FORMAT"
|
||||
)
|
||||
}),
|
||||
("now_datetime", {
|
||||
"label": _("Printing date and time"),
|
||||
"editor_sample": _("2017-05-31 19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"SHORT_DATETIME_FORMAT"
|
||||
)
|
||||
}),
|
||||
("now_time", {
|
||||
"label": _("Printing time"),
|
||||
"editor_sample": _("19:00"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
"TIME_FORMAT"
|
||||
) if ev.date_admission else ""
|
||||
}),
|
||||
))
|
||||
|
||||
|
||||
@@ -219,6 +244,14 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
return d
|
||||
|
||||
|
||||
def _get_attendee_name_part(key, op, order, ev):
|
||||
return op.attendee_name_parts.get(key, '')
|
||||
|
||||
|
||||
def _get_ia_name_part(key, op, order, ev):
|
||||
return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
|
||||
|
||||
def get_variables(event):
|
||||
v = copy.copy(DEFAULT_VARIABLES)
|
||||
|
||||
@@ -227,7 +260,7 @@ def get_variables(event):
|
||||
v['attendee_name_%s' % key] = {
|
||||
'label': _("Attendee name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
|
||||
'evaluate': partial(_get_attendee_name_part, key)
|
||||
}
|
||||
|
||||
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
@@ -237,7 +270,7 @@ def get_variables(event):
|
||||
v['invoice_name_%s' % key] = {
|
||||
'label': _("Invoice address name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
"evaluate": partial(_get_ia_name_part, key)
|
||||
}
|
||||
|
||||
for recv, res in layout_text_variables.send(sender=event):
|
||||
|
||||
@@ -17,7 +17,7 @@ class PluginType(Enum):
|
||||
EXPORT = 4
|
||||
|
||||
|
||||
def get_all_plugins() -> List[type]:
|
||||
def get_all_plugins(event=None) -> List[type]:
|
||||
"""
|
||||
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
|
||||
"""
|
||||
@@ -29,5 +29,13 @@ def get_all_plugins() -> List[type]:
|
||||
meta.app = app
|
||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||
continue
|
||||
|
||||
if hasattr(app, 'is_available') and event:
|
||||
if not app.is_available(event):
|
||||
continue
|
||||
|
||||
plugins.append(meta)
|
||||
return plugins
|
||||
return sorted(
|
||||
plugins,
|
||||
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
|
||||
)
|
||||
|
||||
@@ -143,10 +143,12 @@ class CartManager:
|
||||
for cp in self.positions:
|
||||
if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start:
|
||||
err = error_messages['some_subevent_not_started']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
|
||||
if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end:
|
||||
err = error_messages['some_subevent_ended']
|
||||
cp.addons.all().delete()
|
||||
cp.delete()
|
||||
return err
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
dt = datetime or now()
|
||||
|
||||
# Fetch order position with related objects
|
||||
op = OrderPosition.objects.select_related(
|
||||
op = OrderPosition.all.select_related(
|
||||
'item', 'variation', 'order', 'addon_to'
|
||||
).prefetch_related(
|
||||
'item__questions',
|
||||
@@ -90,10 +90,16 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'answers'
|
||||
).get(pk=op.pk)
|
||||
|
||||
if op.canceled:
|
||||
raise CheckInError(
|
||||
_('This order position has been canceled.'),
|
||||
'unpaid'
|
||||
)
|
||||
|
||||
answers = {a.question: a for a in op.answers.all()}
|
||||
require_answers = []
|
||||
for q in op.item.checkin_questions:
|
||||
if q not in given_answers:
|
||||
if q not in given_answers and q not in answers:
|
||||
require_answers.append(q)
|
||||
|
||||
_save_answers(op, answers, given_answers)
|
||||
|
||||
@@ -81,6 +81,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.invoice_to_zipcode = ia.zipcode
|
||||
invoice.invoice_to_city = ia.city
|
||||
invoice.invoice_to_country = ia.country
|
||||
invoice.invoice_to_beneficiary = ia.beneficiary
|
||||
|
||||
if ia.vat_id:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
||||
@@ -234,7 +235,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
|
||||
if trigger_pdf:
|
||||
invoice_pdf(invoice.pk)
|
||||
|
||||
if order.status in (Order.STATUS_CANCELED, Order.STATUS_REFUNDED):
|
||||
if order.status == Order.STATUS_CANCELED:
|
||||
generate_cancellation(invoice, trigger_pdf)
|
||||
|
||||
return invoice
|
||||
@@ -310,6 +311,7 @@ def build_preview_invoice_pdf(event):
|
||||
invoice.invoice_to_name, invoice.invoice_to_street,
|
||||
invoice.invoice_to_zipcode, invoice.invoice_to_city
|
||||
)
|
||||
invoice.invoice_to_beneficiary = ''
|
||||
invoice.file = None
|
||||
invoice.save()
|
||||
invoice.lines.all().delete()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import smtplib
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
@@ -170,8 +171,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
|
||||
chain(*task_chain).apply_async()
|
||||
|
||||
|
||||
@app.task
|
||||
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
@app.task(bind=True)
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
|
||||
order: int=None, attach_tickets=False) -> bool:
|
||||
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
@@ -219,7 +220,34 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
||||
|
||||
try:
|
||||
backend.send_messages([email])
|
||||
except Exception:
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
|
||||
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
|
||||
logger.exception('Error sending email')
|
||||
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'SMTP code {}'.format(e.smtp_code),
|
||||
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
except Exception as e:
|
||||
if order:
|
||||
order.log_action(
|
||||
'pretix.event.order.email.error',
|
||||
data={
|
||||
'subject': 'Internal error',
|
||||
'message': str(e),
|
||||
'recipient': '',
|
||||
'invoices': [],
|
||||
}
|
||||
)
|
||||
logger.exception('Error sending email')
|
||||
raise SendMailException('Failed to send an email to {}.'.format(to))
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from pretix.base.models.orders import (
|
||||
)
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.payment import BasePaymentProvider, PaymentException
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_qualified,
|
||||
)
|
||||
@@ -124,25 +124,12 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
|
||||
@transaction.atomic
|
||||
def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||
"""
|
||||
Mark this order as refunded. This sets the payment status and returns the order object.
|
||||
:param order: The order to change
|
||||
:param user: The user that performed the change
|
||||
"""
|
||||
if isinstance(order, int):
|
||||
order = Order.objects.get(pk=order)
|
||||
if isinstance(user, int):
|
||||
user = User.objects.get(pk=user)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_REFUNDED
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
oautha = auth.pk if isinstance(auth, OAuthApplication) else None
|
||||
device = auth.pk if isinstance(auth, Device) else None
|
||||
api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None)
|
||||
return _cancel_order(
|
||||
order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -306,7 +293,8 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||
cancellation_fee=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -322,20 +310,54 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
device = Device.objects.get(pk=device)
|
||||
if isinstance(oauth_application, int):
|
||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
||||
with order.event.lock():
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save(update_fields=['status'])
|
||||
if isinstance(cancellation_fee, str):
|
||||
cancellation_fee = Decimal(cancellation_fee)
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
if cancellation_fee:
|
||||
with order.event.lock():
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
position.canceled = True
|
||||
position.save(update_fields=['canceled'])
|
||||
for fee in order.fees.all():
|
||||
fee.canceled = True
|
||||
fee.save(update_fields=['canceled'])
|
||||
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=cancellation_fee,
|
||||
tax_rule=order.event.settings.tax_rate_default,
|
||||
order=order,
|
||||
)
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
|
||||
if order.payment_refund_sum < cancellation_fee:
|
||||
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
order.status = Order.STATUS_PAID
|
||||
order.total = f.value
|
||||
order.save(update_fields=['status', 'total'])
|
||||
|
||||
if i:
|
||||
generate_invoice(order)
|
||||
else:
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
|
||||
data={'cancellation_fee': cancellation_fee})
|
||||
|
||||
if send_mail:
|
||||
email_template = order.event.settings.mail_text_order_canceled
|
||||
@@ -532,7 +554,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee.tax_rule = None # TODO: deprecate
|
||||
fee.save()
|
||||
|
||||
if payment_provider:
|
||||
if payment_provider and not order.require_approval:
|
||||
order.payments.create(
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED,
|
||||
provider=payment_provider.identifier,
|
||||
@@ -1043,7 +1065,10 @@ class OrderChangeManager:
|
||||
'addon_to': opa.addon_to_id,
|
||||
'old_price': opa.price,
|
||||
})
|
||||
opa.delete()
|
||||
opa.canceled = True
|
||||
if opa.voucher:
|
||||
Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
opa.save(update_fields=['canceled'])
|
||||
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
|
||||
'position': op.position.pk,
|
||||
'positionid': op.position.positionid,
|
||||
@@ -1052,7 +1077,10 @@ class OrderChangeManager:
|
||||
'old_price': op.position.price,
|
||||
'addon_to': None,
|
||||
})
|
||||
op.position.delete()
|
||||
op.position.canceled = True
|
||||
if op.position.voucher:
|
||||
Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=F('redeemed') - 1)
|
||||
op.position.save(update_fields=['canceled'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
@@ -1117,7 +1145,7 @@ class OrderChangeManager:
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
split_order.total = sum([p.price for p in split_positions])
|
||||
split_order.total = sum([p.price for p in split_positions if not p.canceled])
|
||||
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
|
||||
pp = self._get_payment_provider()
|
||||
if pp:
|
||||
@@ -1338,10 +1366,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
|
||||
device=None):
|
||||
device=None, cancellation_fee=None, try_auto_refund=False):
|
||||
try:
|
||||
try:
|
||||
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
|
||||
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
|
||||
cancellation_fee)
|
||||
if try_auto_refund:
|
||||
notify_admin = False
|
||||
error = False
|
||||
order = Order.objects.get(pk=order)
|
||||
refund_amount = order.pending_sum * -1
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund = sum(proposals.values()) == refund_amount
|
||||
if can_auto_refund:
|
||||
for p, value in proposals.items():
|
||||
with transaction.atomic():
|
||||
r = order.refunds.create(
|
||||
payment=p,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
state=OrderRefund.REFUND_STATE_CREATED,
|
||||
amount=value,
|
||||
provider=p.provider
|
||||
)
|
||||
order.log_action('pretix.event.order.refund.created', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
with transaction.atomic():
|
||||
r.state = OrderRefund.REFUND_STATE_FAILED
|
||||
r.save()
|
||||
order.log_action('pretix.event.order.refund.failed', {
|
||||
'local_id': r.local_id,
|
||||
'provider': r.provider,
|
||||
'error': str(e)
|
||||
})
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
else:
|
||||
notify_admin = True
|
||||
|
||||
if notify_admin:
|
||||
order.log_action('pretix.event.order.refund.requested')
|
||||
if error:
|
||||
raise OrderError(
|
||||
_('There was an error while trying to send the money back to you. Please contact the event organizer for further information.')
|
||||
)
|
||||
return ret
|
||||
except LockTimeoutException:
|
||||
self.retry()
|
||||
except (MaxRetriesExceededError, LockTimeoutException):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Max, OuterRef, Q, Subquery
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.base.models import LogEntry, Quota
|
||||
from pretix.celery_app import app
|
||||
@@ -26,7 +29,8 @@ def refresh_quota_caches():
|
||||
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
|
||||
).filter(
|
||||
Q(cached_availability_time__isnull=True) |
|
||||
Q(cached_availability_time__lt=F('last_activity'))
|
||||
Q(cached_availability_time__lt=F('last_activity')) |
|
||||
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
|
||||
)
|
||||
for q in quotas:
|
||||
q.availability()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Case, Count, F, Sum, Value, When
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
|
||||
@@ -79,18 +79,22 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
'variations'
|
||||
).order_by('category__position', 'category_id', 'position', 'name')
|
||||
|
||||
qs = OrderPosition.objects
|
||||
qs = OrderPosition.all
|
||||
if subevent:
|
||||
qs = qs.filter(subevent=subevent)
|
||||
counters = qs.filter(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
).values(
|
||||
'item', 'variation', 'order__status'
|
||||
'item', 'variation', 'status'
|
||||
).annotate(cnt=Count('id'), price=Sum('price'), tax_value=Sum('tax_value')).order_by()
|
||||
|
||||
states = {
|
||||
'canceled': Order.STATUS_CANCELED,
|
||||
'refunded': Order.STATUS_REFUNDED,
|
||||
'paid': Order.STATUS_PAID,
|
||||
'pending': Order.STATUS_PENDING,
|
||||
'expired': Order.STATUS_EXPIRED,
|
||||
@@ -99,7 +103,7 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
for l, s in states.items():
|
||||
num[l] = {
|
||||
(p['item'], p['variation']): (p['cnt'], p['price'], p['price'] - p['tax_value'])
|
||||
for p in counters if p['order__status'] == s
|
||||
for p in counters if p['status'] == s
|
||||
}
|
||||
|
||||
num['total'] = dictsum(num['pending'], num['paid'])
|
||||
@@ -149,16 +153,21 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
|
||||
payment_items = []
|
||||
|
||||
if not subevent:
|
||||
counters = OrderFee.objects.filter(
|
||||
counters = OrderFee.all.filter(
|
||||
order__event=event
|
||||
).annotate(
|
||||
status=Case(
|
||||
When(canceled=True, then=Value('c')),
|
||||
default=F('order__status')
|
||||
)
|
||||
).values(
|
||||
'fee_type', 'internal_type', 'order__status'
|
||||
'fee_type', 'internal_type', 'status'
|
||||
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()
|
||||
|
||||
for l, s in states.items():
|
||||
num[l] = {
|
||||
(o['fee_type'], o['internal_type']): (o['cnt'], o['value'], o['value'] - o['tax_value'])
|
||||
for o in counters if o['order__status'] == s
|
||||
for o in counters if o['status'] == s
|
||||
}
|
||||
num['total'] = dictsum(num['pending'], num['paid'])
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ def get_tickets_for_order(order):
|
||||
|
||||
if p.multi_download_enabled:
|
||||
try:
|
||||
if len(order.positions_with_tickets) == 0:
|
||||
continue
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=order, provider=p.identifier, file__isnull=False
|
||||
).last()
|
||||
@@ -132,11 +134,7 @@ def get_tickets_for_order(order):
|
||||
except:
|
||||
logger.exception('Failed to generate ticket.')
|
||||
else:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to and not order.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not order.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
for pos in order.positions_with_tickets:
|
||||
try:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=pos, provider=p.identifier, file__isnull=False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
@@ -64,6 +65,10 @@ DEFAULTS = {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_address_beneficiary': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'invoice_address_vatid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -88,6 +93,10 @@ DEFAULTS = {
|
||||
'default': '30',
|
||||
'type': int
|
||||
},
|
||||
'payment_explanation': {
|
||||
'default': '',
|
||||
'type': LazyI18nString
|
||||
},
|
||||
'payment_term_days': {
|
||||
'default': '14',
|
||||
'type': int
|
||||
@@ -204,6 +213,10 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'event_list_availability': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'event_list_type': {
|
||||
'default': 'list',
|
||||
'type': str
|
||||
@@ -216,6 +229,30 @@ DEFAULTS = {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
},
|
||||
'cancel_allow_user_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'cancel_allow_user_paid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_fees': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
},
|
||||
'cancel_allow_user_paid_keep_percentage': {
|
||||
'default': '0.00',
|
||||
'type': Decimal,
|
||||
},
|
||||
'cancel_allow_user_paid_until': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
},
|
||||
'contact_mail': {
|
||||
'default': None,
|
||||
'type': str
|
||||
|
||||
@@ -133,12 +133,12 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
}, indent=4)
|
||||
yield 'emails-by-attendee.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_email
|
||||
for op in OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False)
|
||||
for op in OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False)
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
OrderPosition.objects.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
||||
OrderPosition.all.filter(order__event=self.event, attendee_email__isnull=False).update(attendee_email=None)
|
||||
|
||||
for o in self.event.orders.all():
|
||||
o.email = None
|
||||
@@ -202,7 +202,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'attendee-names.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
|
||||
for op in OrderPosition.objects.filter(
|
||||
for op in OrderPosition.all.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
@@ -211,7 +211,7 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
OrderPosition.objects.filter(
|
||||
OrderPosition.all.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
@@ -267,7 +267,7 @@ class QuestionAnswerShredder(BaseDataShredder):
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'question-answers.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): AnswerSerializer(op.answers.all(), many=True).data
|
||||
for op in OrderPosition.objects.filter(order__event=self.event).prefetch_related('answers')
|
||||
for op in OrderPosition.all.filter(order__event=self.event).prefetch_related('answers')
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
|
||||
@@ -65,11 +65,11 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
responses.append((receiver, response))
|
||||
return sorted(responses, key=lambda r: (receiver.__module__, receiver.__name__))
|
||||
return responses
|
||||
|
||||
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
@@ -89,12 +89,55 @@ class EventPluginSignal(django.dispatch.Signal):
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._live_receivers(sender):
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
named[chain_kwarg_name] = response
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
return response
|
||||
|
||||
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
|
||||
"""
|
||||
Send signal from sender to all connected receivers. If a receiver raises an exception
|
||||
instead of returning a value, the exception is included as the result instead of
|
||||
stopping the response chain at the offending receiver.
|
||||
|
||||
sender is required to be an instance of ``pretix.base.models.Event``.
|
||||
"""
|
||||
if sender and not isinstance(sender, Event):
|
||||
raise ValueError("Sender needs to be an event.")
|
||||
|
||||
responses = []
|
||||
if (
|
||||
not self.receivers
|
||||
or self.sender_receivers_cache.get(sender) is NO_RECEIVERS
|
||||
):
|
||||
return []
|
||||
|
||||
if not app_cache:
|
||||
_populate_app_cache()
|
||||
|
||||
for receiver in self._sorted_receivers(sender):
|
||||
if self._is_active(sender, receiver):
|
||||
try:
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
except Exception as err:
|
||||
responses.append((receiver, err))
|
||||
else:
|
||||
responses.append((receiver, response))
|
||||
return responses
|
||||
|
||||
def _sorted_receivers(self, sender):
|
||||
orig_list = self._live_receivers(sender)
|
||||
sorted_list = sorted(
|
||||
orig_list,
|
||||
key=lambda receiver: (
|
||||
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
||||
receiver.__module__,
|
||||
receiver.__name__,
|
||||
)
|
||||
)
|
||||
return sorted_list
|
||||
|
||||
|
||||
class DeprecatedSignal(django.dispatch.Signal):
|
||||
|
||||
@@ -183,7 +226,7 @@ register_sales_channels = django.dispatch.Signal(
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known sales channels types. Receivers should return an
|
||||
instance of a subclass of pretix.base.channels.SalesChannel or a list of such
|
||||
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Bad Request" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-frown-o big-icon"></i>
|
||||
<h1>{% trans "Bad Request" %}</h1>
|
||||
<p>{% trans "We were unable to parse your request." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<i class="fa fa-frown-o fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Bad Request" %}</h1>
|
||||
<p>{% trans "We were unable to parse your request." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Permission denied" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-lock big-icon"></i>
|
||||
<h1>{% trans "Permission denied" %}</h1>
|
||||
<p>{% trans "You do not have access to this page." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<i class="fa fa-fw fa-lock big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Permission denied" %}</h1>
|
||||
<p>{% trans "You do not have access to this page." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Not found" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-meh-o big-icon"></i>
|
||||
<h1>{% trans "Not found" %}</h1>
|
||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<i class="fa fa-meh-o fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Not found" %}</h1>
|
||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if request.user.is_staff and not staff_session %}
|
||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
|
||||
<p>
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-default" id="button-sudo">
|
||||
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Internal Server Error" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-bolt big-icon"></i>
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if request.sentry.id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ request.sentry.id }}
|
||||
<i class="fa fa-bolt big-icon fa-fw"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Internal Server Error" %}</h1>
|
||||
<p>{% trans "We had trouble processing your request." %}</p>
|
||||
<p>{% trans "If this problem persists, please contact us." %}</p>
|
||||
{% if request.sentry.id %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
If you contact us, please send us the following code:
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{{ request.sentry.id }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>{{ exception }}</p>
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
· <a id='reload' href='#'>{% trans "Try again" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load staticfiles %}
|
||||
{% block title %}{% trans "Verification failed" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-frown-o big-icon"></i>
|
||||
<h1>{% trans "Verification failed" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
We could not verify that this request really was sent from you. For security reasons, we therefore cannot process it.
|
||||
{% endblocktrans %}</p>
|
||||
{% if no_referer %}
|
||||
<p>{{ no_referer1 }}</p>
|
||||
<p>{{ no_referer2 }}</p>
|
||||
{% elif no_cookie %}
|
||||
<p>{{ no_cookie1 }}</p>
|
||||
<p>{{ no_cookie2 }}</p>
|
||||
{% else %}
|
||||
<i class="fa fa-frown-o big-icon fa-fw"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Verification failed" %}</h1>
|
||||
<p>{% blocktrans trimmed %}
|
||||
Please go back to the last page, refresh this page and then try again. If the problem persists, please get in touch with us.
|
||||
We could not verify that this request really was sent from you. For security reasons, we therefore cannot
|
||||
process it.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
{% if no_referer %}
|
||||
<p>{{ no_referer1 }}</p>
|
||||
<p>{{ no_referer2 }}</p>
|
||||
{% elif no_cookie %}
|
||||
<p>{{ no_cookie1 }}</p>
|
||||
<p>{{ no_cookie2 }}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
Please go back to the last page, refresh this page and then try again. If the problem persists, please
|
||||
get in touch with us.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
<p class="links">
|
||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
||||
</p>
|
||||
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -16,6 +16,6 @@
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</body>
|
||||
<script src="{% static "pretixbase/js/errors.js" %}"></script>
|
||||
</html>
|
||||
|
||||
@@ -70,14 +70,12 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
|
||||
for pos in order.positions.all():
|
||||
if pos.addon_to_id and not self.event.settings.ticket_download_addons:
|
||||
continue
|
||||
if not pos.item.admission and not self.event.settings.ticket_download_nonadm:
|
||||
continue
|
||||
for pos in order.positions_with_tickets:
|
||||
fname, __, content = self.generate(pos)
|
||||
zipf.writestr('{}-{}{}'.format(
|
||||
order.code, pos.positionid, os.path.splitext(fname)[1]
|
||||
|
||||
@@ -146,6 +146,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
invoice_form_class = BaseInvoiceAddressForm
|
||||
invoice_name_form_class = BaseInvoiceNameForm
|
||||
only_user_visible = True
|
||||
all_optional = False
|
||||
|
||||
@cached_property
|
||||
def _positions_for_questions(self):
|
||||
@@ -189,12 +190,14 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
|
||||
return self.invoice_name_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional
|
||||
)
|
||||
return self.invoice_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
event=self.request.event,
|
||||
instance=self.invoice_address, validate_vat_id=False
|
||||
instance=self.invoice_address, validate_vat_id=False,
|
||||
all_optional=self.all_optional,
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -34,6 +34,7 @@ def contextprocessor(request):
|
||||
ctx = {
|
||||
'url_name': url.url_name,
|
||||
'settings': settings,
|
||||
'django_settings': settings,
|
||||
'DEBUG': settings.DEBUG,
|
||||
}
|
||||
_html_head = []
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db.models import Q
|
||||
from django.forms import formset_factory
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
from django.utils.translation import (
|
||||
pgettext, pgettext_lazy, ugettext_lazy as _,
|
||||
@@ -159,6 +161,15 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
return slug
|
||||
|
||||
|
||||
class EventChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return mark_safe('{}<br /><span class="text-muted">{} · {}</span>'.format(
|
||||
escape(str(obj)),
|
||||
obj.get_date_range_display() if not obj.has_subevents else _("Event series"),
|
||||
obj.slug
|
||||
))
|
||||
|
||||
|
||||
class EventWizardCopyForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
@@ -177,7 +188,7 @@ class EventWizardCopyForm(forms.Form):
|
||||
kwargs.pop('has_subevents')
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['copy_from_event'] = forms.ModelChoiceField(
|
||||
self.fields['copy_from_event'] = EventChoiceField(
|
||||
label=_("Copy configuration from"),
|
||||
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
|
||||
widget=forms.RadioSelect,
|
||||
@@ -397,11 +408,6 @@ class EventSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||
)
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Allow users to cancel unpaid orders"),
|
||||
help_text=_("If checked, users can cancel orders by themselves as long as they are not yet paid."),
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
@@ -435,6 +441,39 @@ class EventSettingsForm(SettingsForm):
|
||||
)
|
||||
|
||||
|
||||
class CancelSettingsForm(SettingsForm):
|
||||
cancel_allow_user = forms.BooleanField(
|
||||
label=_("Customers can cancel their unpaid orders"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid = forms.BooleanField(
|
||||
label=_("Customers can cancel their paid orders"),
|
||||
help_text=_("Paid money will be automatically paid back if the payment method allows it. "
|
||||
"Otherwise, a manual refund will be created for you to process manually."),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep = forms.DecimalField(
|
||||
label=_("Keep a fixed cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_fees = forms.BooleanField(
|
||||
label=_("Keep payment, shipping and service fees"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_keep_percentage = forms.DecimalField(
|
||||
label=_("Keep a percentual cancellation fee"),
|
||||
required=False
|
||||
)
|
||||
cancel_allow_user_paid_until = RelativeDateTimeField(
|
||||
label=_("Do not allow cancellations after"),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PaymentSettingsForm(SettingsForm):
|
||||
payment_term_days = forms.IntegerField(
|
||||
label=_('Payment term in days'),
|
||||
@@ -478,6 +517,16 @@ class PaymentSettingsForm(SettingsForm):
|
||||
help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This "
|
||||
"will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.")
|
||||
)
|
||||
payment_explanation = I18nFormField(
|
||||
widget=I18nTextarea,
|
||||
widget_kwargs={'attrs': {
|
||||
'rows': 3,
|
||||
}},
|
||||
required=False,
|
||||
label=_("Guidance text"),
|
||||
help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, "
|
||||
"if you want.")
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -567,6 +616,11 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_address_beneficiary = forms.BooleanField(
|
||||
label=_("Ask for beneficiary"),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||
required=False
|
||||
)
|
||||
invoice_include_free = forms.BooleanField(
|
||||
label=_("Show free products on invoices"),
|
||||
help_text=_("Note that invoices will never be generated for orders that contain only free "
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from datetime import datetime, time
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.db.models.functions import Coalesce, ExtractWeekDay
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import get_current_timezone, make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import DatePickerWidget
|
||||
from pretix.base.models import (
|
||||
Checkin, Event, Invoice, Item, Order, OrderPayment, OrderPosition,
|
||||
OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
|
||||
@@ -97,14 +100,13 @@ class OrderFilterForm(FilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
@@ -173,7 +175,7 @@ class OrderFilterForm(FilterForm):
|
||||
|
||||
class EventOrderFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt'}
|
||||
'datetime': 'datetime', 'status': 'status'}
|
||||
|
||||
item = forms.ModelChoiceField(
|
||||
label=_('Products'),
|
||||
@@ -198,14 +200,13 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
label=_('Order status'),
|
||||
choices=(
|
||||
('', _('All orders')),
|
||||
('p', _('Paid')),
|
||||
('n', _('Pending')),
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
(Order.STATUS_PENDING, _('Pending')),
|
||||
('o', _('Pending (overdue)')),
|
||||
('np', _('Pending or paid')),
|
||||
('e', _('Expired')),
|
||||
('ne', _('Pending or expired')),
|
||||
('c', _('Canceled')),
|
||||
('r', _('Refunded')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')),
|
||||
(Order.STATUS_EXPIRED, _('Expired')),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')),
|
||||
(Order.STATUS_CANCELED, _('Canceled')),
|
||||
('pa', _('Approval pending')),
|
||||
('overpaid', _('Overpaid')),
|
||||
('underpaid', _('Underpaid')),
|
||||
@@ -243,10 +244,10 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = super().filter_qs(qs)
|
||||
|
||||
if fdata.get('item'):
|
||||
qs = qs.filter(positions__item=fdata.get('item'))
|
||||
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False)
|
||||
|
||||
if fdata.get('subevent'):
|
||||
qs = qs.filter(positions__subevent=fdata.get('subevent'))
|
||||
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False)
|
||||
|
||||
if fdata.get('question') and fdata.get('answer') is not None:
|
||||
q = fdata.get('question')
|
||||
@@ -274,16 +275,19 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
qs = qs.annotate(has_answer=Exists(answers)).filter(has_answer=True)
|
||||
|
||||
if fdata.get('status') == 'overpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
)
|
||||
elif fdata.get('status') == 'pendingpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
|
||||
& Q(require_approval=False)
|
||||
)
|
||||
elif fdata.get('status') == 'underpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
status=Order.STATUS_PAID,
|
||||
pending_sum_t__gt=0
|
||||
@@ -299,7 +303,7 @@ class EventOrderFilterForm(OrderFilterForm):
|
||||
|
||||
class OrderSearchFilterForm(OrderFilterForm):
|
||||
orders = {'code': 'code', 'email': 'email', 'total': 'total',
|
||||
'datetime': 'datetime', 'status': 'status', 'pcnt': 'pcnt',
|
||||
'datetime': 'datetime', 'status': 'status',
|
||||
'event': 'event'}
|
||||
|
||||
organizer = forms.ModelChoiceField(
|
||||
@@ -355,6 +359,11 @@ class SubEventFilterForm(FilterForm):
|
||||
),
|
||||
required=False
|
||||
)
|
||||
date = forms.DateField(
|
||||
label=_('Date'),
|
||||
required=False,
|
||||
widget=DatePickerWidget
|
||||
)
|
||||
weekday = forms.ChoiceField(
|
||||
label=_('Weekday'),
|
||||
choices=(
|
||||
@@ -378,6 +387,10 @@ class SubEventFilterForm(FilterForm):
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['date'].widget = DatePickerWidget()
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
@@ -407,6 +420,20 @@ class SubEventFilterForm(FilterForm):
|
||||
Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query)
|
||||
)
|
||||
|
||||
if fdata.get('date'):
|
||||
date_start = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
time(hour=0, minute=0, second=0, microsecond=0)
|
||||
), get_current_timezone())
|
||||
date_end = make_aware(datetime.combine(
|
||||
fdata.get('date'),
|
||||
time(hour=23, minute=59, second=59, microsecond=999999)
|
||||
), get_current_timezone())
|
||||
qs = qs.filter(
|
||||
Q(date_to__isnull=True, date_from__gte=date_start, date_from__lte=date_end) |
|
||||
Q(date_to__isnull=False, date_from__lte=date_end, date_to__gte=date_start)
|
||||
)
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
|
||||
|
||||
@@ -297,6 +297,16 @@ class ItemCreateForm(I18nModelForm):
|
||||
]
|
||||
|
||||
|
||||
class TicketNullBooleanSelect(forms.NullBooleanSelect):
|
||||
def __init__(self, attrs=None):
|
||||
choices = (
|
||||
('1', _('Choose automatically depending on event settings')),
|
||||
('2', _('Yes, if ticket generation is enabled in general')),
|
||||
('3', _('Never')),
|
||||
)
|
||||
super(forms.NullBooleanSelect, self).__init__(attrs, choices)
|
||||
|
||||
|
||||
class ItemUpdateForm(I18nModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -340,6 +350,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'max_per_order',
|
||||
'min_per_order',
|
||||
'checkin_attention',
|
||||
'generate_tickets',
|
||||
'original_price'
|
||||
]
|
||||
field_classes = {
|
||||
@@ -349,6 +360,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||
'generate_tickets': TicketNullBooleanSelect()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -75,6 +77,40 @@ class ConfirmPaymentForm(forms.Form):
|
||||
del self.fields['force']
|
||||
|
||||
|
||||
class CancelForm(ConfirmPaymentForm):
|
||||
send_email = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Notify user by e-mail'),
|
||||
initial=True
|
||||
)
|
||||
cancellation_fee = forms.DecimalField(
|
||||
required=False,
|
||||
max_digits=10, decimal_places=2,
|
||||
localize=True,
|
||||
label=_('Keep a cancellation fee of'),
|
||||
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
|
||||
'to a paid cancellation fee. Payment and shipping fees will be canceled as well, so include them '
|
||||
'in your cancellation fee if you want to keep them. Please always enter a gross value, '
|
||||
'tax will be calculated automatically.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
prs = self.instance.payment_refund_sum
|
||||
if prs > 0:
|
||||
change_decimal_field(self.fields['cancellation_fee'], self.instance.event.currency)
|
||||
self.fields['cancellation_fee'].initial = Decimal('0.00')
|
||||
self.fields['cancellation_fee'].max_value = prs
|
||||
else:
|
||||
del self.fields['cancellation_fee']
|
||||
|
||||
def clean_cancellation_fee(self):
|
||||
val = self.cleaned_data['cancellation_fee']
|
||||
if val > self.instance.payment_refund_sum:
|
||||
raise ValidationError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
|
||||
return val
|
||||
|
||||
|
||||
class MarkPaidForm(ConfirmPaymentForm):
|
||||
amount = forms.DecimalField(
|
||||
required=True,
|
||||
@@ -86,7 +122,7 @@ class MarkPaidForm(ConfirmPaymentForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['amount'], self.instance.event.currency)
|
||||
self.fields['amount'].initial = max(0, self.instance.pending_sum)
|
||||
self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
|
||||
|
||||
|
||||
class ExporterForm(forms.Form):
|
||||
@@ -365,8 +401,7 @@ class OrderRefundForm(forms.Form):
|
||||
required=False,
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('mark_refunded', _('Mark the complete order as refunded. The order will be canceled and all tickets will '
|
||||
'no longer work. This can not be reverted.')),
|
||||
('mark_refunded', _('Cancel the order. All tickets will no longer work. This can not be reverted.')),
|
||||
('mark_pending', _('Mark the order as pending and allow the user to pay the open amount with another '
|
||||
'payment method.')),
|
||||
('do_nothing', _('Do nothing and keep the order as it is.')),
|
||||
@@ -389,7 +424,7 @@ class OrderRefundForm(forms.Form):
|
||||
self.order = kwargs.pop('order')
|
||||
super().__init__(*args, **kwargs)
|
||||
change_decimal_field(self.fields['partial_amount'], self.order.event.currency)
|
||||
if self.order.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
|
||||
if self.order.status == Order.STATUS_CANCELED:
|
||||
del self.fields['action']
|
||||
|
||||
def clean_partial_amount(self):
|
||||
|
||||
@@ -238,6 +238,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
('calendar', _('Calendar'))
|
||||
)
|
||||
)
|
||||
event_list_availability = forms.BooleanField(
|
||||
label=_('Show availability in event overviews'),
|
||||
help_text=_('If checked, the list of events will show if events are sold out. This might '
|
||||
'make for longer page loading times if you have lots of events and the shown status might be out '
|
||||
'of date for up to two minutes.'),
|
||||
required=False
|
||||
)
|
||||
organizer_link_back = forms.BooleanField(
|
||||
label=_('Link back to organizer overview on all event pages'),
|
||||
required=False
|
||||
@@ -255,6 +262,13 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
],
|
||||
help_text=_('Only respected by modern browsers.')
|
||||
)
|
||||
favicon = ExtFileField(
|
||||
label=_('Favicon'),
|
||||
ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"),
|
||||
required=False,
|
||||
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
|
||||
'We recommend a size of at least 200x200px to accomodate most devices.')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -57,7 +57,7 @@ class UserEditForm(forms.ModelForm):
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
|
||||
@@ -63,7 +63,7 @@ def _display_order_changed(event: Event, logentry: LogEntry):
|
||||
old_item = str(event.items.get(pk=data['old_item']))
|
||||
if data['old_variation']:
|
||||
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) removed.').format(
|
||||
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
old_item=old_item,
|
||||
old_price=money_filter(Decimal(data['old_price']), event.currency),
|
||||
@@ -187,6 +187,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'toggled.'),
|
||||
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
|
||||
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
|
||||
'pretix.event.order.email.error': _('Sending of an email has failed.'),
|
||||
'pretix.event.order.email.custom_sent': _('A custom email has been sent.'),
|
||||
'pretix.event.order.email.download_reminder_sent': _('An email has been sent with a reminder that the ticket '
|
||||
'is available for download.'),
|
||||
@@ -211,8 +212,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
||||
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
||||
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
||||
'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'),
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
|
||||
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
|
||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
|
||||
@@ -88,6 +88,14 @@ def get_event_navigation(request: HttpRequest):
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.invoice',
|
||||
},
|
||||
{
|
||||
'label': pgettext_lazy('action', 'Cancellation'),
|
||||
'url': reverse('control:event.settings.cancel', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': url.url_name == 'event.settings.cancel',
|
||||
},
|
||||
{
|
||||
'label': _('Widget'),
|
||||
'url': reverse('control:event.settings.widget', kwargs={
|
||||
|
||||
@@ -201,6 +201,16 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
"""
|
||||
|
||||
order_position_buttons = EventPluginSignal(
|
||||
providing_args=["order", "position", "request"]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to display additional buttons for a single position of an order.
|
||||
|
||||
As with all plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
"""
|
||||
|
||||
nav_event_settings = EventPluginSignal(
|
||||
providing_args=['request']
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load staticfiles %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
<title>{{ django_settings.PRETIX_INSTANCE_NAME }}</title>
|
||||
{% compress css %}
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
|
||||
{% endcompress %}
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
<meta name="referrer" content="origin">
|
||||
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
|
||||
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
40
src/pretix/control/templates/pretixcontrol/event/cancel.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Cancellation settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of unpaid or free orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_until layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Cancellation of paid orders" %}</legend>
|
||||
{% bootstrap_field form.cancel_allow_user_paid layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %}
|
||||
{% bootstrap_field form.cancel_allow_user_paid_until layout="control" %}
|
||||
{% if not gets_notification %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
If a user requests cancels a paid order and the money can not be refunded automatically, e.g.
|
||||
due to the selected payment method, you will need to take manual action. However, you have
|
||||
currently turned off notifications for this event.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:user.settings.notifications" %}" class="btn btn-default">
|
||||
{% trans "Change notification settings" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -23,6 +23,7 @@
|
||||
{% bootstrap_field form.invoice_name_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_company_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_beneficiary layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Your invoice details" %}</legend>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
{% bootstrap_field form.payment_term_expire_automatically layout="control" %}
|
||||
{% bootstrap_field form.payment_term_accept_late layout="control" %}
|
||||
{% bootstrap_field form.tax_rate_default layout="control" %}
|
||||
{% bootstrap_field form.payment_explanation layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Payment settings" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<fieldset>
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_required layout="control" %}
|
||||
{% bootstrap_field sform.cancel_allow_user layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Waiting list" %}</legend>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
item to an existing or newly created quota.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% elif not object.is_available_by_time %}
|
||||
{% elif object.pk and not object.is_available_by_time %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
This product is currently not being sold since you configured below that it should only be available in a certain timeframe.
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<legend>{% trans "Additional settings" %}</legend>
|
||||
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
|
||||
{% bootstrap_field form.require_approval layout="control" %}
|
||||
{% bootstrap_field form.generate_tickets layout="control" %}
|
||||
{% for f in plugin_forms %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
title="{% trans "Only visible with a voucher" %}"></span>
|
||||
{% elif i.require_voucher %}
|
||||
<span class="fa fa-tags fa-fw text-muted" data-toggle="tooltip"
|
||||
title="{% trans "Can only bought using a voucher" %}"></span>
|
||||
title="{% trans "Can only be bought using a voucher" %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if i.category %}{{ i.category.name }}{% endif %}</td>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
|
||||
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
|
||||
<option value="r" {% if request.GET.status == "r" %}selected="selected"{% endif %}>{% trans "Refunded" %}</option>
|
||||
</select>
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}
|
||||
{% trans "Cancel order" %}
|
||||
{% endblock %}
|
||||
@@ -10,16 +11,21 @@
|
||||
<p>{% blocktrans trimmed %}
|
||||
Do you really want to cancel this order? You cannot revert this action.
|
||||
{% endblocktrans %}</p>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<p>{% blocktrans trimmed %}
|
||||
This will <strong>not</strong> automatically transfer the money back, but you will be offered options to
|
||||
refund the payment afterwards.
|
||||
{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" href="">
|
||||
<form method="post" href="" class="">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="status" value="c"/>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="send_email" value="on" checked="checked">
|
||||
{% trans "Notify user by e-mail" %}
|
||||
</label>
|
||||
</div>
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.send_email layout='' %}
|
||||
{% if form.cancellation_fee %}
|
||||
{% bootstrap_field form.cancellation_fee layout='' %}
|
||||
{% endif %}
|
||||
<div class="row checkout-button-row">
|
||||
<div class="col-md-4">
|
||||
<a class="btn btn-block btn-default btn-lg"
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
<label>
|
||||
<input name="{{ position.form.prefix }}-operation" type="radio" value="cancel"
|
||||
{% if position.form.operation.value == "cancel" %}checked="checked"{% endif %}>
|
||||
{% trans "Remove from order" %}
|
||||
{% trans "Cancel position" %}
|
||||
{% if position.addons.exists %}
|
||||
<em class="text-danger">
|
||||
{% trans "Removing this position will also remove all add-ons to this position." %}
|
||||
|
||||
@@ -50,11 +50,6 @@
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
|
||||
@@ -155,7 +150,7 @@
|
||||
<dd>
|
||||
{% for i in invoices %}
|
||||
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if not i.canceled %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
@@ -171,11 +166,11 @@
|
||||
action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs"
|
||||
{% if order.status != "r" and order.status != "c" %}
|
||||
{% if order.status != "c" %}
|
||||
data-toggle="tooltip"
|
||||
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
|
||||
{% endif %}>
|
||||
{% if order.status == "r" or order.status == "c" %}
|
||||
{% if order.status == "c" %}
|
||||
{% trans "Generate cancellation" %}
|
||||
{% else %}
|
||||
{% trans "Cancel and reissue" %}
|
||||
@@ -234,7 +229,7 @@
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for line in items.positions %}
|
||||
<div class="row-fluid product-row">
|
||||
<div class="row-fluid product-row {% if line.canceled %}pos-canceled{% endif %}">
|
||||
<div class="col-md-9 col-xs-6">
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
@@ -260,6 +255,24 @@
|
||||
<br/>
|
||||
<span class="fa fa-calendar"></span> {{ line.subevent.name }} · {{ line.subevent.get_date_range_display }}
|
||||
{% endif %}
|
||||
{% if not line.canceled %}
|
||||
<div class="position-buttons">
|
||||
{% if line.generate_ticket %}
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if line.has_questions %}
|
||||
<dl>
|
||||
{% if line.item.admission and event.settings.attendee_names_asked %}
|
||||
@@ -315,7 +328,7 @@
|
||||
{% if line.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -325,7 +338,7 @@
|
||||
{% if line.tax_rate and line.price %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -336,7 +349,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for fee in items.fees %}
|
||||
<div class="row-fluid product-row">
|
||||
<div class="row-fluid product-row {% if fee.canceled %}pos-canceled{% endif %}">
|
||||
<div class="col-md-4 col-xs-6">
|
||||
<strong>{{ fee.get_fee_type_display }}</strong>
|
||||
{% if fee.description %}
|
||||
@@ -349,7 +362,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -359,7 +372,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -468,6 +481,11 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if order.payment_refund_sum > 0 %}
|
||||
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
{% trans "Create a refund" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
<span class="label label-warning {{ class }}">{% trans "Pending" %}</span>
|
||||
{% endif %}
|
||||
{% elif order.status == "p" %}
|
||||
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
|
||||
{% if order.count_positions == 0 %}
|
||||
<span class="label label-info {{ class }}">{% trans "Canceled (paid fee)" %}</span>
|
||||
{% else %}
|
||||
<span class="label label-success {{ class }}">{% trans "Paid" %}</span>
|
||||
{% endif %}
|
||||
{% elif order.status == "e" %} {# expired #}
|
||||
<span class="label label-danger {{ class }}">{% trans "Expired" %}</span>
|
||||
{% elif order.status == "c" %}
|
||||
<span class="label label-danger {{ class }}">{% trans "Canceled" %}</span>
|
||||
{% elif order.status == "r" %}
|
||||
<span class="label label-danger {{ class }}">{% trans "Refunded" %}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -101,9 +101,7 @@
|
||||
<th class="text-right">{% trans "Order total" %}
|
||||
<a href="?{% url_replace request 'ordering' '-total' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'total' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th class="text-right">{% trans "Positions" %}
|
||||
<a href="?{% url_replace request 'ordering' '-pcnt' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'pcnt' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th class="text-right">{% trans "Positions" %}</th>
|
||||
<th class="text-right">{% trans "Status" %}
|
||||
<a href="?{% url_replace request 'ordering' '-status' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'status' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
@@ -142,7 +140,7 @@
|
||||
{% endif %}
|
||||
{{ o.total|money:request.event.currency }}
|
||||
</td>
|
||||
<td class="text-right">{{ o.pcnt }}</td>
|
||||
<td class="text-right">{{ o.pcnt|default_if_none:"0" }}</td>
|
||||
<td class="text-right">{% include "pretixcontrol/orders/fragment_order_status.html" with order=o %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load order_overview %}
|
||||
{% block title %}{% trans "Sales overview" %}{% endblock %}
|
||||
{% block title %}{% trans "Order overview" %}{% endblock %}
|
||||
{% block content %}
|
||||
{% url "control:event.orders" organizer=request.event.organizer.slug event=request.event.slug as listurl %}
|
||||
<div class="pull-right">
|
||||
@@ -30,8 +30,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th>{% trans "Canceled" %}</th>
|
||||
<th>{% trans "Refunded" %}</th>
|
||||
<th>{% trans "Canceled" %}¹</th>
|
||||
<th>{% trans "Expired" %}</th>
|
||||
<th colspan="3">{% trans "Purchased" %}</th>
|
||||
</tr>
|
||||
@@ -39,7 +38,6 @@
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>{% trans "Pending" %}</th>
|
||||
<th>{% trans "Paid" %}</th>
|
||||
<th>{% trans "Total" %}</th>
|
||||
@@ -51,7 +49,6 @@
|
||||
<tr class="category">
|
||||
<th>{{ tup.0 }}</th>
|
||||
<th>{{ tup.0.num.canceled|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.refunded|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.expired|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.pending|togglesum:request.event.currency }}</th>
|
||||
<th>{{ tup.0.num.paid|togglesum:request.event.currency }}</th>
|
||||
@@ -66,11 +63,6 @@
|
||||
{{ item.num.canceled|togglesum:request.event.currency }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ listurl }}?item={{ item.id }}&status=r&provider={{ item.provider }}">
|
||||
{{ item.num.refunded|togglesum:request.event.currency }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ listurl }}?item={{ item.id }}&status=e&provider={{ item.provider }}">
|
||||
{{ item.num.expired|togglesum:request.event.currency }}
|
||||
@@ -95,7 +87,6 @@
|
||||
<tr class="variation {% if tup.0 %}categorized{% endif %}">
|
||||
<td>{{ var }}</td>
|
||||
<td>{{ var.num.canceled|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.refunded|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.expired|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.pending|togglesum:request.event.currency }}</td>
|
||||
<td>{{ var.num.paid|togglesum:request.event.currency }}</td>
|
||||
@@ -110,7 +101,6 @@
|
||||
<tr class="total">
|
||||
<th>{% trans "Total" %}</th>
|
||||
<th>{{ total.num.canceled|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.refunded|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.expired|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.pending|togglesum:request.event.currency }}</th>
|
||||
<th>{{ total.num.paid|togglesum:request.event.currency }}</th>
|
||||
@@ -119,4 +109,7 @@
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="help-block">
|
||||
¹ {% trans "If you click links in this column, you will only find orders that are canceled completely, while the numbers also include single canceled positions within valid orders." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{% bootstrap_field form.organizer_logo_image layout="control" %}
|
||||
{% bootstrap_field form.organizer_homepage_text layout="control" %}
|
||||
{% bootstrap_field form.event_list_type layout="control" %}
|
||||
{% bootstrap_field form.event_list_availability layout="control" %}
|
||||
{% bootstrap_field form.organizer_link_back layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
@@ -26,6 +27,7 @@
|
||||
{% bootstrap_field form.theme_color_success layout="control" %}
|
||||
{% bootstrap_field form.theme_color_danger layout="control" %}
|
||||
{% bootstrap_field form.primary_font layout="control" %}
|
||||
{% bootstrap_field form.favicon layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -22,13 +22,16 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<form class="row filter-form" action="" method="get">
|
||||
<div class="col-md-4 col-sm-6 col-xs-12">
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-6 col-xs-12">
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.date layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.weekday layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 col-xs-12">
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="alert alert-warning">
|
||||
{% trans "This voucher already has been used. It is not recommended to modify it." %}
|
||||
<ul>
|
||||
{% for op in voucher.orderposition_set.all %}
|
||||
<li><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=op.order.code %}">
|
||||
{% blocktrans with code=op.order.code %}Order {{ code }}{% endblocktrans %}
|
||||
{% for order in voucher.distinct_orders %}
|
||||
<li><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans with code=order.code %}Order {{ code }}{% endblocktrans %}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -124,6 +124,7 @@ urlpatterns = [
|
||||
url(r'^settings/email/preview$', event.MailSettingsPreview.as_view(), name='event.settings.mail.preview'),
|
||||
url(r'^settings/email/layoutpreview$', event.MailSettingsRendererPreview.as_view(),
|
||||
name='event.settings.mail.preview.layout'),
|
||||
url(r'^settings/cancel', event.CancelSettings.as_view(), name='event.settings.cancel'),
|
||||
url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'),
|
||||
url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'),
|
||||
url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'),
|
||||
@@ -199,6 +200,9 @@ urlpatterns = [
|
||||
name='event.order.regeninvoice'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/invoices/(?P<id>\d+)/reissue$', orders.OrderInvoiceReissue.as_view(),
|
||||
name='event.order.reissueinvoice'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<position>\d+)/(?P<output>[^/]+)/$',
|
||||
orders.OrderDownload.as_view(),
|
||||
name='event.order.download.ticket'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/answer/(?P<answer>[^/]+)/$',
|
||||
orders.AnswerDownload.as_view(),
|
||||
name='event.order.download.answer'),
|
||||
|
||||
@@ -199,7 +199,7 @@ class Forgot(TemplateView):
|
||||
has_redis = settings.HAS_REDIS
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
user = User.objects.get(email__iexact=email)
|
||||
|
||||
if has_redis:
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
@@ -272,8 +272,8 @@ def event_index(request, organizer, event):
|
||||
}
|
||||
|
||||
ctx['has_overpaid_orders'] = Order.annotate_overpayments(request.event.orders).filter(
|
||||
Q(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0))
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
).exists()
|
||||
ctx['has_pending_orders_with_full_payment'] = Order.annotate_overpayments(request.event.orders).filter(
|
||||
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q(require_approval=False)
|
||||
|
||||
@@ -41,10 +41,10 @@ from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.rich_text import markdown_compile
|
||||
from pretix.control.forms.event import (
|
||||
CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm,
|
||||
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
|
||||
PaymentSettingsForm, ProviderForm, QuickSetupForm,
|
||||
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
CancelSettingsForm, CommentForm, DisplaySettingsForm, EventDeleteForm,
|
||||
EventMetaValueForm, EventSettingsForm, EventUpdateForm,
|
||||
InvoiceSettingsForm, MailSettingsForm, PaymentSettingsForm, ProviderForm,
|
||||
QuickSetupForm, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
|
||||
TicketSettingsForm, WidgetCodeForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
@@ -188,7 +188,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['plugins'] = [p for p in get_all_plugins() if not p.name.startswith('.')
|
||||
context['plugins'] = [p for p in get_all_plugins(self.object) if not p.name.startswith('.')
|
||||
and getattr(p, 'visible', True)]
|
||||
context['plugins_active'] = self.object.get_plugins()
|
||||
return context
|
||||
@@ -204,7 +204,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
|
||||
self.object = self.get_object()
|
||||
|
||||
plugins_available = {
|
||||
p.module: p for p in get_all_plugins()
|
||||
p.module: p for p in get_all_plugins(self.object)
|
||||
if not p.name.startswith('.') and getattr(p, 'visible', True)
|
||||
}
|
||||
|
||||
@@ -407,6 +407,43 @@ class InvoiceSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
})
|
||||
|
||||
|
||||
class CancelSettings(EventSettingsViewMixin, EventSettingsFormView):
|
||||
model = Event
|
||||
form_class = CancelSettingsForm
|
||||
template_name = 'pretixcontrol/event/cancel.html'
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:event.settings.cancel', kwargs={
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'event': self.request.event.slug
|
||||
})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['gets_notification'] = self.request.user.notifications_send and (
|
||||
(
|
||||
self.request.user.notification_settings.filter(
|
||||
event=self.request.event,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=True
|
||||
).exists()
|
||||
) or (
|
||||
self.request.user.notification_settings.filter(
|
||||
event__isnull=True,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=True
|
||||
).exists() and not
|
||||
self.request.user.notification_settings.filter(
|
||||
event=self.request.event,
|
||||
action_type='pretix.event.order.refund.requested',
|
||||
enabled=False
|
||||
).exists()
|
||||
)
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class InvoicePreview(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_change_event_settings'
|
||||
|
||||
|
||||
@@ -860,9 +860,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
|
||||
data = {
|
||||
k: (form.cleaned_data.get(k).name
|
||||
if isinstance(form.cleaned_data.get(k), File)
|
||||
else form.cleaned_data.get(k))
|
||||
k: form.cleaned_data.get(k)
|
||||
for k in form.changed_data
|
||||
}
|
||||
for f in self.plugin_forms:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user