mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Data model for transactional history (#2147)
This commit is contained in:
@@ -1404,6 +1404,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
|
||||
order.create_transactions(is_new=True, fees=fees, positions=pos_map.values())
|
||||
return order
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import F, Max, Q
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
from tqdm import tqdm
|
||||
|
||||
from pretix.base.models import Order
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create missing order transactions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--slowdown",
|
||||
dest="interval",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Interval for staggered execution. If set to a value different then zero, we will "
|
||||
"wait this many milliseconds between every order we process.",
|
||||
)
|
||||
|
||||
@scopes_disabled()
|
||||
def handle(self, *args, **options):
|
||||
t = 0
|
||||
qs = Order.objects.annotate(
|
||||
last_transaction=Max('transactions__created')
|
||||
).filter(
|
||||
Q(last_transaction__isnull=True) | Q(last_modified__gt=F('last_transaction')),
|
||||
require_approval=False,
|
||||
).prefetch_related(
|
||||
'all_positions', 'all_fees'
|
||||
)
|
||||
for o in tqdm(qs):
|
||||
if o.last_transaction is None:
|
||||
tn = o.create_transactions(
|
||||
positions=o.all_positions.all(),
|
||||
fees=o.all_fees.all(),
|
||||
dt_now=o.datetime,
|
||||
migrated=True,
|
||||
is_new=True,
|
||||
_backfill_before_cancellation=True,
|
||||
)
|
||||
o.create_transactions(
|
||||
positions=o.all_positions.all(),
|
||||
fees=o.all_fees.all(),
|
||||
dt_now=o.cancellation_date or (o.expires if o.status == Order.STATUS_EXPIRED else o.datetime),
|
||||
migrated=True,
|
||||
)
|
||||
else:
|
||||
tn = o.create_transactions(
|
||||
positions=o.all_positions.all(),
|
||||
fees=o.all_fees.all(),
|
||||
dt_now=now(),
|
||||
migrated=True,
|
||||
)
|
||||
if tn:
|
||||
t += 1
|
||||
time.sleep(options.get('slowdown', 0) / 1000)
|
||||
|
||||
self.stderr.write(self.style.SUCCESS(f'Created transactions for {t} orders.'))
|
||||
38
src/pretix/base/migrations/0200_transaction.py
Normal file
38
src/pretix/base/migrations/0200_transaction.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 3.2.4 on 2021-10-18 10:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0199_auto_20211005_1050'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('datetime', models.DateTimeField(db_index=True)),
|
||||
('migrated', models.BooleanField(default=False)),
|
||||
('positionid', models.PositiveIntegerField(default=1, null=True)),
|
||||
('count', models.IntegerField(default=1)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('tax_rate', models.DecimalField(decimal_places=2, max_digits=7)),
|
||||
('tax_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('fee_type', models.CharField(max_length=100, null=True)),
|
||||
('internal_type', models.CharField(max_length=255, null=True)),
|
||||
('item', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.item')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.order')),
|
||||
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.subevent')),
|
||||
('tax_rule', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.taxrule')),
|
||||
('variation', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.itemvariation')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('datetime', 'pk'),
|
||||
},
|
||||
),
|
||||
]
|
||||
99
src/pretix/base/models/_transactions.py
Normal file
99
src/pretix/base/models/_transactions.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""
|
||||
This module contains helper functions that are supposed to call out code paths missing calls to
|
||||
``Order.create_transaction()`` by actively breaking them. Read the docstring of the ``Transaction`` class for a
|
||||
detailed reasoning why this exists.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
dirty_transactions = threading.local()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
fail_loudly = os.getenv('PRETIX_DIRTY_TRANSACTIONS_QUIET', 'false') not in ('true', 'True', 'on', '1')
|
||||
|
||||
|
||||
class DirtyTransactionsForOrderException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _fail(message):
|
||||
if fail_loudly:
|
||||
raise DirtyTransactionsForOrderException(message)
|
||||
else:
|
||||
logger.warning(message, stack_info=True)
|
||||
|
||||
|
||||
def _check_for_dirty_orders():
|
||||
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||
dirty_transactions.order_ids = set()
|
||||
if not dirty_transactions.order_ids and dirty_transactions.order_ids != {None}:
|
||||
_fail(
|
||||
"In the transaction that just ended, you created or modified an Order, OrderPosition, or OrderFee "
|
||||
"object in a way that you should have called `order.create_transactions()` afterwards. The transaction "
|
||||
"still went through and your data can be fixed with the `create_order_transactions` management command "
|
||||
"but you should update your code to prevent this from happening."
|
||||
)
|
||||
dirty_transactions.order_ids.clear()
|
||||
|
||||
|
||||
def _transactions_mark_order_dirty(order_id, using=None):
|
||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
||||
# or not.
|
||||
for frame in inspect.stack():
|
||||
if 'pretix/base/models/orders' in frame.filename:
|
||||
continue
|
||||
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
|
||||
return
|
||||
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
||||
# This went through non-test code, let's consider it non-test
|
||||
break
|
||||
|
||||
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||
dirty_transactions.order_ids = set()
|
||||
dirty_transactions.order_ids.add(order_id)
|
||||
conn = transaction.get_connection(using)
|
||||
if not conn.in_atomic_block:
|
||||
_fail(
|
||||
"You modified an Order, OrderPosition, or OrderFee object in a way that should create "
|
||||
"a new Transaction object within the same database transaction, however you are not "
|
||||
"doing it inside a database transaction!"
|
||||
)
|
||||
|
||||
if _check_for_dirty_orders not in [func for savepoint_id, func in conn.run_on_commit]:
|
||||
transaction.on_commit(_check_for_dirty_orders, using)
|
||||
|
||||
|
||||
def _transactions_mark_order_clean(order_id):
|
||||
if getattr(dirty_transactions, 'order_ids', None) is None:
|
||||
dirty_transactions.order_ids = set()
|
||||
try:
|
||||
dirty_transactions.order_ids.remove(order_id)
|
||||
except KeyError:
|
||||
pass
|
||||
@@ -1431,7 +1431,7 @@ class SubEvent(EventMixin, LoggedModel):
|
||||
return self.event.currency
|
||||
|
||||
def allow_delete(self):
|
||||
return not self.orderposition_set.exists()
|
||||
return not self.orderposition_set.exists() and not self.transaction_set.exists()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
clear_cache = kwargs.pop('clear_cache', False)
|
||||
|
||||
@@ -693,9 +693,9 @@ class Item(LoggedModel):
|
||||
return res
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
from pretix.base.models.orders import OrderPosition, Transaction
|
||||
|
||||
return not OrderPosition.all.filter(item=self).exists()
|
||||
return not Transaction.objects.filter(item=self).exists() and not OrderPosition.all.filter(item=self).exists()
|
||||
|
||||
@property
|
||||
def includes_mixed_tax_rate(self):
|
||||
@@ -958,10 +958,13 @@ class ItemVariation(models.Model):
|
||||
return self.position < other.position
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
from pretix.base.models.orders import (
|
||||
CartPosition, OrderPosition, Transaction,
|
||||
)
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(variation=self).exists()
|
||||
not Transaction.objects.filter(variation=self).exists()
|
||||
and not OrderPosition.objects.filter(variation=self).exists()
|
||||
and not CartPosition.objects.filter(variation=self).exists()
|
||||
)
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import order_gracefully_delete
|
||||
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ._transactions import (
|
||||
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
|
||||
)
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
@@ -262,6 +265,11 @@ class Order(LockModel, LoggedModel):
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields():
|
||||
self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
|
||||
|
||||
def gracefully_delete(self, user=None, auth=None):
|
||||
from . import GiftCard, GiftCardTransaction, Membership, Voucher
|
||||
|
||||
@@ -289,6 +297,7 @@ class Order(LockModel, LoggedModel):
|
||||
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
|
||||
OrderPosition.all.filter(order=self).delete()
|
||||
OrderFee.all.filter(order=self).delete()
|
||||
Transaction.objects.filter(order=self).delete()
|
||||
self.refunds.all().delete()
|
||||
self.payments.all().delete()
|
||||
self.event.cache.delete('complain_testmode_orders')
|
||||
@@ -444,7 +453,27 @@ class Order(LockModel, LoggedModel):
|
||||
self.datetime = now()
|
||||
if not self.expires:
|
||||
self.set_expires()
|
||||
super().save(**kwargs)
|
||||
|
||||
is_new = not self.pk
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
if 'require_approval' not in self.get_deferred_fields() and 'status' not in self.get_deferred_fields():
|
||||
status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval
|
||||
if status_paid_or_pending != self.__initial_status_paid_or_pending or not self.pk:
|
||||
_transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None))
|
||||
elif (
|
||||
not kwargs.get('force_save_with_deferred_fields', None) and
|
||||
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
|
||||
):
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
r = super().save(**kwargs)
|
||||
|
||||
if is_new:
|
||||
_transactions_mark_order_dirty(self.pk, using=kwargs.get('using', None))
|
||||
|
||||
return r
|
||||
|
||||
def touch(self):
|
||||
self.save(update_fields=['last_modified'])
|
||||
@@ -999,6 +1028,59 @@ class Order(LockModel, LoggedModel):
|
||||
continue
|
||||
yield op
|
||||
|
||||
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
|
||||
_backfill_before_cancellation=False, save=True):
|
||||
dt_now = dt_now or now()
|
||||
|
||||
# Count the transactions we already have
|
||||
current_transaction_count = Counter()
|
||||
if not is_new:
|
||||
for t in self.transactions.all():
|
||||
current_transaction_count[Transaction.key(t)] += t.count
|
||||
|
||||
# Count the transactions we'd actually need
|
||||
target_transaction_count = Counter()
|
||||
if (_backfill_before_cancellation or self.status in (Order.STATUS_PENDING, Order.STATUS_PAID)) and not self.require_approval:
|
||||
positions = self.positions.all() if positions is None else positions
|
||||
for p in positions:
|
||||
if p.canceled and not _backfill_before_cancellation:
|
||||
continue
|
||||
target_transaction_count[Transaction.key(p)] += 1
|
||||
|
||||
fees = self.fees.all() if fees is None else fees
|
||||
for f in fees:
|
||||
if f.canceled and not _backfill_before_cancellation:
|
||||
continue
|
||||
target_transaction_count[Transaction.key(f)] += 1
|
||||
|
||||
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
||||
create = []
|
||||
for k in keys:
|
||||
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype = k
|
||||
d = target_transaction_count[k] - current_transaction_count[k]
|
||||
if d:
|
||||
create.append(Transaction(
|
||||
order=self,
|
||||
datetime=dt_now,
|
||||
migrated=migrated,
|
||||
positionid=positionid,
|
||||
count=d,
|
||||
item_id=itemid,
|
||||
variation_id=variationid,
|
||||
subevent_id=subeventid,
|
||||
price=price,
|
||||
tax_rate=taxrate,
|
||||
tax_rule_id=taxruleid,
|
||||
tax_value=taxvalue,
|
||||
fee_type=feetype,
|
||||
internal_type=internaltype,
|
||||
))
|
||||
create.sort(key=lambda t: (0 if t.count < 0 else 1, t.positionid or 0))
|
||||
if save:
|
||||
Transaction.objects.bulk_create(create)
|
||||
_transactions_mark_order_clean(self.pk)
|
||||
return create
|
||||
|
||||
|
||||
def answerfile_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
|
||||
@@ -1468,6 +1550,7 @@ class OrderPayment(models.Model):
|
||||
'message': can_be_paid
|
||||
}, user=user, auth=auth)
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
status_change = self.order.status != Order.STATUS_PENDING
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save(update_fields=['status'])
|
||||
|
||||
@@ -1481,6 +1564,8 @@ class OrderPayment(models.Model):
|
||||
if overpaid:
|
||||
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
|
||||
order_paid.send(self.order.event, order=self.order)
|
||||
if status_change:
|
||||
self.order.create_transactions()
|
||||
|
||||
def fail(self, info=None, user=None, auth=None):
|
||||
"""
|
||||
@@ -1958,6 +2043,12 @@ class OrderFee(models.Model):
|
||||
def net_value(self):
|
||||
return self.value - self.tax_value
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.get_deferred_fields():
|
||||
self.__initial_transaction_key = Transaction.key(self)
|
||||
self.__initial_canceled = self.canceled
|
||||
|
||||
def __str__(self):
|
||||
if self.description:
|
||||
return '{} - {}'.format(self.get_fee_type_display(), self.description)
|
||||
@@ -1996,6 +2087,15 @@ class OrderFee(models.Model):
|
||||
if self.tax_rate is None:
|
||||
self._calculate_tax()
|
||||
self.order.touch()
|
||||
|
||||
if not self.get_deferred_fields():
|
||||
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
||||
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
||||
elif not kwargs.get('force_save_with_deferred_fields', None):
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, **kwargs):
|
||||
@@ -2010,7 +2110,7 @@ class OrderPosition(AbstractPosition):
|
||||
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.
|
||||
you want all objects, you need to use ``OrderPosition.all`` instead.
|
||||
|
||||
:param order: The order this position is a part of
|
||||
:type order: Order
|
||||
@@ -2061,6 +2161,12 @@ class OrderPosition(AbstractPosition):
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
objects = ActivePositionManager()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.get_deferred_fields():
|
||||
self.__initial_transaction_key = Transaction.key(self)
|
||||
self.__initial_canceled = self.canceled
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
@@ -2104,6 +2210,7 @@ class OrderPosition(AbstractPosition):
|
||||
op._calculate_tax()
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
ops.append(op)
|
||||
cp_mapping[cartpos.pk] = op
|
||||
for answ in cartpos.answers.all():
|
||||
answ.orderposition = op
|
||||
@@ -2169,6 +2276,14 @@ class OrderPosition(AbstractPosition):
|
||||
if not self.pseudonymization_id:
|
||||
self.assign_pseudonymization_id()
|
||||
|
||||
if not self.get_deferred_fields():
|
||||
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
||||
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
||||
elif not kwargs.get('force_save_with_deferred_fields', None):
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@scopes_disabled()
|
||||
@@ -2264,6 +2379,143 @@ class OrderPosition(AbstractPosition):
|
||||
)
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
"""
|
||||
Transactions are a data structure that is redundant on the first sight but makes it possible to create good
|
||||
financial reporting.
|
||||
|
||||
To understand this, think of "orders" as something like a contractual relationship between the organizer and the
|
||||
customer which requires to customer to pay some money and the organizer to provide a ticket.
|
||||
|
||||
The ``Order``, ``OrderPosition``, and ``OrderFee`` models combined give a representation of the current contractual
|
||||
status of this relationship, i.e. how much and what is owed. The ``OrderPayment`` and ``OrderRefund`` models indicate
|
||||
the "other side" of the relationship, i.e. how much of the financial obligation has been met so far.
|
||||
|
||||
However, while ``OrderPayment`` and ``OrderRefund`` objects are "final" and no longer change once they reached their
|
||||
final state, ``Order``, ``OrderPosition`` and ``OrderFee`` are highly mutable and can chane at any time, e.g. if
|
||||
the customer moves their booking to a different day or a discount is applied retroactively.
|
||||
|
||||
Therefore those models can be used to answer the question "how many tickets of type X have been sold for my event
|
||||
as of today?" but they cannot accurately answer the question "how many tickets of type X have been sold for my event
|
||||
as of last month?" because they lack this kind of historical information.
|
||||
|
||||
Transactions help here because they are "immutable copies" or "modification records" of call positions and fees
|
||||
at the time of their creation and change. They only record data that is usually relevant for financial reporting,
|
||||
such as amounts, prices, products and dates involved. They do not record data like attendee names etc.
|
||||
|
||||
Even before the introduction of the Transaction Model pretix *did* store historical data for auditability in the
|
||||
LogEntry model. However, it's almost impossible to do efficient reporting on that data.
|
||||
|
||||
Transactions should never be generated manually but only through the ``order.create_transactions()``
|
||||
method which should be called **within the same database transaction**.
|
||||
|
||||
The big downside of this approach is that you need to remember to update transaction records every time you change
|
||||
or create orders in new code paths. The mechanism introduced in ``pretix.base.models._transactions`` as well as
|
||||
the ``save()`` methods of ``Order``, ``OrderPosition`` and ``OrderFee`` intends to help you notice if you missed
|
||||
it. The only thing this *doesn't* catch is usage of ``OrderPosition.objects.bulk_create`` (and likewise for
|
||||
``bulk_update`` and ``OrderFee``).
|
||||
|
||||
:param id: ID of the transaction
|
||||
:param order: Order the transaction belongs to
|
||||
:param datetime: Date and time of the transaction
|
||||
:param migrated: Whether this object was reconstructed because the order was created before transactions where introduced
|
||||
:param positionid: Affected Position ID, in case this transaction represents a change in an order position
|
||||
:param count: An amount, multiplicator for price etc. For order positions this can *currently* only be -1 or +1, for
|
||||
fees it can also be more in special cases
|
||||
:param item: ``Item``, in case this transaction represents a change in an order position
|
||||
:param variation: ``ItemVariation``, in case this transaction represents a change in an order position
|
||||
:param subevent: ``subevent``, in case this transaction represents a change in an order position
|
||||
:param price: Price of the changed position
|
||||
:param tax_rate: Tax rate of the changed position
|
||||
:param tax_rule: Used tax rule
|
||||
:param tax_value: Tax value in event currency
|
||||
:param fee_type: Fee type code in case this transaction represents a change in an order fee
|
||||
:param internal_type: Internal fee type in case this transaction represents a change in an order fee
|
||||
"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
related_name='transactions',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
)
|
||||
datetime = models.DateTimeField(
|
||||
verbose_name=_("Date"),
|
||||
db_index=True,
|
||||
)
|
||||
migrated = models.BooleanField(
|
||||
default=False
|
||||
)
|
||||
positionid = models.PositiveIntegerField(default=1, null=True, blank=True)
|
||||
count = models.IntegerField(
|
||||
default=1
|
||||
)
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Item"),
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
variation = models.ForeignKey(
|
||||
ItemVariation,
|
||||
null=True, blank=True,
|
||||
verbose_name=_("Variation"),
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
subevent = models.ForeignKey(
|
||||
SubEvent,
|
||||
null=True, blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
price = models.DecimalField(
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
'TaxRule',
|
||||
on_delete=models.PROTECT,
|
||||
null=True, blank=True
|
||||
)
|
||||
tax_value = models.DecimalField(
|
||||
max_digits=10, decimal_places=2,
|
||||
verbose_name=_('Tax value')
|
||||
)
|
||||
fee_type = models.CharField(
|
||||
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
||||
)
|
||||
internal_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = 'datetime', 'pk'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.fee_type and not self.item:
|
||||
raise ValidationError('Should set either item or fee type')
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def key(obj):
|
||||
if isinstance(obj, Transaction):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
|
||||
elif isinstance(obj, OrderPosition):
|
||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, None, None)
|
||||
elif isinstance(obj, OrderFee):
|
||||
return (None, None, None, None, obj.value, obj.tax_rate,
|
||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
|
||||
raise ValueError('invalid state') # noqa
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
A cart position is similar to an order line, except that it is not
|
||||
|
||||
@@ -162,10 +162,13 @@ class TaxRule(LoggedModel):
|
||||
pass
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import OrderFee, OrderPosition
|
||||
from pretix.base.models.orders import (
|
||||
OrderFee, OrderPosition, Transaction,
|
||||
)
|
||||
|
||||
return (
|
||||
not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
not Transaction.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderFee.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
|
||||
|
||||
@@ -33,6 +33,7 @@ from pretix.base.models import (
|
||||
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
||||
User,
|
||||
)
|
||||
from pretix.base.models.orders import Transaction
|
||||
from pretix.base.orderimport import get_all_columns
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
@@ -146,6 +147,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
||||
# quota check?
|
||||
with event.lock():
|
||||
with transaction.atomic():
|
||||
save_transactions = []
|
||||
for o in orders:
|
||||
o.total = sum([c.price for c in o._positions]) # currently no support for fees
|
||||
if o.total == Decimal('0.00'):
|
||||
@@ -187,6 +189,8 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user)
|
||||
user=user,
|
||||
data={'source': 'import'}
|
||||
)
|
||||
save_transactions += o.create_transactions(is_new=True, fees=[], positions=o._positions, save=False)
|
||||
Transaction.objects.bulk_create(save_transactions)
|
||||
|
||||
for o in orders:
|
||||
with language(o.locale, event.settings.region):
|
||||
|
||||
@@ -181,6 +181,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
for m in position.granted_memberships.all():
|
||||
m.canceled = False
|
||||
m.save()
|
||||
order.create_transactions()
|
||||
else:
|
||||
raise OrderError(is_available)
|
||||
|
||||
@@ -202,6 +203,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
if new_date < now():
|
||||
raise OrderError(_('The new expiry date needs to be in the future.'))
|
||||
|
||||
@transaction.atomic
|
||||
def change(was_expired=True):
|
||||
order.expires = new_date
|
||||
if was_expired:
|
||||
@@ -221,6 +223,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
num_invoices = order.invoices.filter(is_cancellation=False).count()
|
||||
if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices and invoice_qualified(order):
|
||||
generate_invoice(order)
|
||||
order.create_transactions()
|
||||
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
change(was_expired=False)
|
||||
@@ -262,6 +265,7 @@ def mark_order_expired(order, user=None, auth=None):
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i and not i.refered.exists():
|
||||
generate_cancellation(i)
|
||||
order.create_transactions()
|
||||
|
||||
order_expired.send(order.event, order=order)
|
||||
return order
|
||||
@@ -293,6 +297,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth, ignore_date=True, force=force)
|
||||
except Quota.QuotaExceededException:
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
order.create_transactions()
|
||||
|
||||
order_approved.send(order.event, order=order)
|
||||
|
||||
@@ -352,6 +357,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
for position in order.positions.all():
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
order.create_transactions()
|
||||
|
||||
order_denied.send(order.event, order=order)
|
||||
|
||||
@@ -452,6 +458,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
order.total = cancellation_fee
|
||||
order.cancellation_date = now()
|
||||
order.save(update_fields=['status', 'cancellation_date', 'total'])
|
||||
order.create_transactions(positions=[])
|
||||
|
||||
if cancel_invoice and i:
|
||||
invoices.append(generate_invoice(order))
|
||||
@@ -460,6 +467,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.cancellation_date = now()
|
||||
order.save(update_fields=['status', 'cancellation_date'])
|
||||
order.create_transactions()
|
||||
|
||||
for position in order.positions.all():
|
||||
assign_ticket_secret(
|
||||
@@ -904,7 +912,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
fee=pf
|
||||
)
|
||||
|
||||
OrderPosition.transform_cart_positions(positions, order)
|
||||
orderpositions = OrderPosition.transform_cart_positions(positions, order)
|
||||
order.create_transactions(positions=orderpositions, fees=fees, is_new=True)
|
||||
order.log_action('pretix.event.order.placed')
|
||||
if order.require_approval:
|
||||
order.log_action('pretix.event.order.placed.require_approval')
|
||||
@@ -2123,6 +2132,7 @@ class OrderChangeManager:
|
||||
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):
|
||||
self._reissue_invoice()
|
||||
self._clear_tickets_cache()
|
||||
self.order.create_transactions()
|
||||
self.order.touch()
|
||||
self._check_paid_price_change()
|
||||
self._check_paid_to_free()
|
||||
|
||||
@@ -52,18 +52,22 @@
|
||||
{% if order.status == 'n' or order.status == 'e' %}
|
||||
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=p"
|
||||
class="btn {% if overpaid >= 0 %}btn-success{% else %}btn-default{% endif %}">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Mark as paid" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.extend" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-clock-o"></span>
|
||||
{% trans "Extend payment term" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if order.cancel_allowed %}
|
||||
<a href="{% url "control:event.order.transition" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}?status=c" class="btn btn-default">
|
||||
<span class="fa fa-ban"></span>
|
||||
{% trans "Cancel order" %}
|
||||
</a>
|
||||
{% elif order.status == 'c' %}
|
||||
<a href="{% url "control:event.order.reactivate" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-reply"></span>
|
||||
{% trans "Reactivate order" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -71,11 +75,17 @@
|
||||
|
||||
<a href="{% eventurl request.event "presale:event.order" order=order.code secret=order.secret %}"
|
||||
class="btn btn-default" target="_blank">
|
||||
<span class="fa fa-eye"></span>
|
||||
{% trans "View order as user" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.mail_history" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-envelope-o"></span>
|
||||
{% trans "View email history" %}
|
||||
</a>
|
||||
<a href="{% url "control:event.order.transactions" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
|
||||
<span class="fa fa-list"></span>
|
||||
{% trans "View transaction history" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Transaction history" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% trans "Transaction history" %}
|
||||
<a class="btn btn-link btn-lg"
|
||||
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% blocktrans trimmed with order=order.code %}
|
||||
Back to order {{ order }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</h1>
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Product" %}</th>
|
||||
<th class="text-right flip">{% trans "Quantity" %}</th>
|
||||
<th class="text-right flip">{% trans "Tax rate" %}</th>
|
||||
<th class="text-right flip">{% trans "Tax value" %}</th>
|
||||
<th class="text-right flip">{% trans "Price" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in transactions %}
|
||||
<tr class="{% if t.count < 0 %}text-danger{% endif %}">
|
||||
<td>
|
||||
{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if t.migrated %}
|
||||
<span class="fa fa-warning text-warning"
|
||||
data-toggle="tooltip"
|
||||
title="{% trans 'This order was created before we introduced this table, therefore this data might be inaccurate.' %}"
|
||||
></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if t.item %}
|
||||
{{ t.item }}
|
||||
{% if t.variation %}
|
||||
– {{ t.variation }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if t.fee_type %}
|
||||
{{ t.get_fee_type_display }}
|
||||
{% endif %}
|
||||
{% if t.subevent %}
|
||||
<br>{{ t.subevent }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">{{ t.count }} ×</td>
|
||||
<td class="text-right flip">{{ t.tax_rate }} %</td>
|
||||
<td class="text-right flip">{{ t.tax_value|money:request.event.currency }}</td>
|
||||
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -345,6 +345,7 @@ urlpatterns = [
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
|
||||
orders.OrderCancellationRequestDelete.as_view(),
|
||||
name='event.order.cancellationrequests.delete'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
|
||||
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
|
||||
@@ -401,6 +401,18 @@ class OrderDetail(OrderView):
|
||||
}
|
||||
|
||||
|
||||
class OrderTransactions(OrderView):
|
||||
template_name = 'pretixcontrol/order/transactions.html'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['transactions'] = self.order.transactions.select_related(
|
||||
'item', 'variation', 'subevent'
|
||||
).order_by('datetime')
|
||||
return ctx
|
||||
|
||||
|
||||
class OrderDownload(AsyncAction, OrderView):
|
||||
task = generate
|
||||
permission = 'can_view_orders'
|
||||
|
||||
@@ -1173,6 +1173,9 @@ def test_order_mark_paid_canceled(token_client, organizer, event, order):
|
||||
def test_order_mark_paid_expired_quota_free(token_client, organizer, event, order, quota):
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 0
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
@@ -1186,6 +1189,8 @@ def test_order_mark_paid_expired_quota_free(token_client, organizer, event, orde
|
||||
order.refresh_from_db()
|
||||
assert len(djmail.outbox) == 0
|
||||
assert order.status == Order.STATUS_PAID
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1223,6 +1228,9 @@ def test_order_mark_paid_locked(token_client, organizer, event, order):
|
||||
def test_order_reactivate(token_client, organizer, event, order, quota):
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 0
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/reactivate/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
@@ -1230,6 +1238,8 @@ def test_order_reactivate(token_client, organizer, event, order, quota):
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['status'] == Order.STATUS_PENDING
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1244,6 +1254,9 @@ def test_order_reactivate_invalid(token_client, organizer, event, order):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_mark_canceled_pending(token_client, organizer, event, order):
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 2
|
||||
djmail.outbox = []
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format(
|
||||
@@ -1253,6 +1266,8 @@ def test_order_mark_canceled_pending(token_client, organizer, event, order):
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['status'] == Order.STATUS_CANCELED
|
||||
assert len(djmail.outbox) == 1
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1302,6 +1317,9 @@ def test_order_mark_canceled_expired(token_client, organizer, event, order):
|
||||
def test_order_mark_paid_canceled_keep_fee(token_client, organizer, event, order):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 2
|
||||
with scopes_disabled():
|
||||
order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=order.total)
|
||||
resp = token_client.post(
|
||||
@@ -1316,6 +1334,8 @@ def test_order_mark_paid_canceled_keep_fee(token_client, organizer, event, order
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_PAID
|
||||
assert order.total == Decimal('6.00')
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1502,6 +1522,9 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order,
|
||||
order.save()
|
||||
quota.size = 2
|
||||
quota.save()
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 0
|
||||
newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d")
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format(
|
||||
@@ -1514,12 +1537,17 @@ def test_order_extend_expired_quota_left(token_client, organizer, event, order,
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_PENDING
|
||||
assert order.expires.astimezone(event.timezone).strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59"
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_pending_approve(token_client, organizer, event, order):
|
||||
order.require_approval = True
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.create_transactions()
|
||||
assert order.transactions.count() == 0
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/{}/approve/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
@@ -1528,6 +1556,8 @@ def test_order_pending_approve(token_client, organizer, event, order):
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['status'] == Order.STATUS_PENDING
|
||||
assert not resp.data['require_approval']
|
||||
with scopes_disabled():
|
||||
assert order.transactions.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -1690,6 +1720,8 @@ def test_order_create(token_client, organizer, event, item, quota, question):
|
||||
answ = pos.answers.first()
|
||||
assert answ.question == question
|
||||
assert answ.answer == "S"
|
||||
with scopes_disabled():
|
||||
assert o.transactions.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -26,6 +26,7 @@ from decimal import Decimal
|
||||
import pytest
|
||||
import pytz
|
||||
from django.core import mail as djmail
|
||||
from django.db.models import F, Sum
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django_countries.fields import Country
|
||||
@@ -204,7 +205,19 @@ def test_expiring(event):
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=12,
|
||||
)
|
||||
ticket = Item.objects.create(event=event, name='Early-bird ticket',
|
||||
default_price=Decimal('23.00'), admission=True)
|
||||
OrderPosition.objects.create(
|
||||
order=o1, item=ticket, variation=None,
|
||||
price=Decimal("0.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
|
||||
)
|
||||
OrderPosition.objects.create(
|
||||
order=o2, item=ticket, variation=None,
|
||||
price=Decimal("12.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
|
||||
)
|
||||
generate_invoice(o2)
|
||||
o1.create_transactions()
|
||||
o2.create_transactions()
|
||||
expire_orders(None)
|
||||
o1 = Order.objects.get(id=o1.id)
|
||||
assert o1.status == Order.STATUS_PENDING
|
||||
@@ -212,6 +225,9 @@ def test_expiring(event):
|
||||
assert o2.status == Order.STATUS_EXPIRED
|
||||
assert o2.invoices.count() == 2
|
||||
assert o2.invoices.last().is_cancellation is True
|
||||
assert o1.transactions.count() == 1
|
||||
assert o2.transactions.count() == 2
|
||||
assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -222,8 +238,19 @@ def test_expiring_paid_invoice(event):
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=12,
|
||||
)
|
||||
ticket = Item.objects.create(event=event, name='Early-bird ticket',
|
||||
default_price=Decimal('12.00'), admission=True)
|
||||
q = event.quotas.create(name='Q', size=None)
|
||||
q.items.add(ticket)
|
||||
OrderPosition.objects.create(
|
||||
order=o2, item=ticket, variation=None,
|
||||
price=Decimal("12.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
|
||||
)
|
||||
generate_invoice(o2)
|
||||
o2.create_transactions()
|
||||
assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('12.00')
|
||||
expire_orders(None)
|
||||
assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00')
|
||||
o2 = Order.objects.get(id=o2.id)
|
||||
assert o2.status == Order.STATUS_EXPIRED
|
||||
assert o2.invoices.count() == 2
|
||||
@@ -232,6 +259,8 @@ def test_expiring_paid_invoice(event):
|
||||
).confirm()
|
||||
assert o2.invoices.count() == 3
|
||||
assert o2.invoices.last().is_cancellation is False
|
||||
assert o2.transactions.count() == 3
|
||||
assert o2.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('12.00')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -309,8 +338,17 @@ def test_approve(event):
|
||||
datetime=now(), expires=now() - timedelta(days=10),
|
||||
total=10, require_approval=True, locale='en'
|
||||
)
|
||||
ticket = Item.objects.create(event=event, name='Early-bird ticket',
|
||||
default_price=Decimal('23.00'), admission=True)
|
||||
OrderPosition.objects.create(
|
||||
order=o1, item=ticket, variation=None,
|
||||
price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1
|
||||
)
|
||||
o1.create_transactions()
|
||||
assert o1.transactions.count() == 0
|
||||
approve_order(o1)
|
||||
o1.refresh_from_db()
|
||||
assert o1.transactions.count() == 1
|
||||
assert o1.expires > now()
|
||||
assert o1.status == Order.STATUS_PENDING
|
||||
assert not o1.require_approval
|
||||
@@ -609,6 +647,7 @@ class OrderCancelTests(TestCase):
|
||||
order=self.order, item=self.ticket, variation=None,
|
||||
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2
|
||||
)
|
||||
self.order.create_transactions()
|
||||
generate_invoice(self.order)
|
||||
djmail.outbox = []
|
||||
|
||||
@@ -659,6 +698,8 @@ class OrderCancelTests(TestCase):
|
||||
assert self.order.status == Order.STATUS_CANCELED
|
||||
assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled'
|
||||
assert self.order.invoices.count() == 2
|
||||
assert self.order.transactions.count() == 4
|
||||
assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('0.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_paid_with_too_high_fee(self):
|
||||
@@ -820,6 +861,8 @@ class OrderChangeManagerTests(TestCase):
|
||||
order=self.order, item=self.ticket, variation=None,
|
||||
price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2
|
||||
)
|
||||
self.order.create_transactions(is_new=True)
|
||||
assert self.order.transactions.count() == 2
|
||||
self.ocm = OrderChangeManager(self.order, None)
|
||||
self.quota = self.event.quotas.create(name='Test', size=None)
|
||||
self.quota.items.add(self.ticket)
|
||||
@@ -899,6 +942,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.order.refresh_from_db()
|
||||
assert self.op1.subevent == se2
|
||||
assert self.op1.item == self.shirt
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_success(self):
|
||||
@@ -921,6 +965,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.price == Decimal('23.00')
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
assert self.op1.secret == s
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_success_change_secret(self):
|
||||
@@ -944,6 +989,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.price == Decimal('23.00')
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
assert self.op1.secret != s
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_with_price_success(self):
|
||||
@@ -965,6 +1011,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.subevent == se2
|
||||
assert self.op1.price == Decimal('12.00')
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_subevent_sold_out(self):
|
||||
@@ -983,6 +1030,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.subevent == se1
|
||||
assert self.order.transactions.count() == 2
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_quota_required(self):
|
||||
@@ -1004,6 +1052,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.tax_value == Decimal('3.67')
|
||||
assert self.op1.tax_rule == self.shirt.tax_rule
|
||||
assert self.op1.secret != s
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_keep_price(self):
|
||||
@@ -1018,6 +1067,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.op1.tax_value == Decimal('3.67')
|
||||
assert self.op1.tax_rule == self.shirt.tax_rule
|
||||
assert self.op1.secret == s
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_item_change_price_before_voucher(self):
|
||||
@@ -1074,6 +1124,19 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value
|
||||
assert self.order.total == self.op1.price + self.op2.price
|
||||
|
||||
t0 = self.order.transactions.filter(positionid=self.op1.positionid)[0]
|
||||
assert t0.item == self.ticket
|
||||
assert t0.price == Decimal('23.00')
|
||||
assert t0.count == 1
|
||||
t1 = self.order.transactions.filter(positionid=self.op1.positionid)[1]
|
||||
assert t1.item == self.ticket
|
||||
assert t1.price == Decimal('23.00')
|
||||
assert t1.count == -1
|
||||
t2 = self.order.transactions.filter(positionid=self.op1.positionid)[2]
|
||||
assert t2.item == self.shirt
|
||||
assert t2.price == Decimal('12.00')
|
||||
assert t2.count == 1
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_price_success(self):
|
||||
self.ocm.change_price(self.op1, Decimal('24.00'))
|
||||
@@ -1109,6 +1172,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.canceled
|
||||
assert self.op1.secret == s
|
||||
assert self.order.transactions.count() == 3
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_success_changed_secret(self):
|
||||
@@ -1284,6 +1348,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.op2.refresh_from_db()
|
||||
assert self.op1.item == self.shirt
|
||||
assert self.op2.item == self.shirt
|
||||
assert self.order.transactions.count() == 6
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_multiple_items_quotas_partially_full(self):
|
||||
@@ -1394,6 +1459,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert self.order.total == Decimal('48.00')
|
||||
assert self.order.pending_sum == Decimal('2.00')
|
||||
assert self.order.status == Order.STATUS_PENDING
|
||||
assert self.order.transactions.count() == 4
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_change_paid_stays_paid_when_overpaid(self):
|
||||
@@ -1436,6 +1502,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert round_decimal(nop.price * (1 - 100 / (100 + self.shirt.tax_rule.rate))) == nop.tax_value
|
||||
assert self.order.total == self.op1.price + self.op2.price + nop.price
|
||||
assert nop.positionid == 3
|
||||
assert self.order.transactions.count() == 3
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_add_item_net_price_success(self):
|
||||
@@ -1615,6 +1682,7 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert op.tax_rate == Decimal('100.00')
|
||||
|
||||
assert self.order.total == Decimal('86.00') + fee.value
|
||||
assert self.order.transactions.count() == 7
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_recalculate_country_rate_keep_gross(self):
|
||||
@@ -2973,6 +3041,7 @@ class OrderReactivateTest(TestCase):
|
||||
self.quota.items.add(self.ticket)
|
||||
self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1")
|
||||
generate_invoice(self.order)
|
||||
self.order.create_transactions()
|
||||
djmail.outbox = []
|
||||
|
||||
@classscope(attr='o')
|
||||
@@ -2994,6 +3063,7 @@ class OrderReactivateTest(TestCase):
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_reactivate_paid(self):
|
||||
assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] in (None, Decimal('0.00'))
|
||||
self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5)
|
||||
reactivate_order(self.order)
|
||||
self.order.refresh_from_db()
|
||||
@@ -3001,6 +3071,7 @@ class OrderReactivateTest(TestCase):
|
||||
assert self.order.all_logentries().last().action_type == 'pretix.event.order.reactivated'
|
||||
assert self.order.invoices.count() == 3
|
||||
assert not self.order.cancellation_date
|
||||
assert self.order.transactions.aggregate(s=Sum(F('price') * F('count')))['s'] == Decimal('46.00')
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_reactivate_sold_out(self):
|
||||
|
||||
@@ -490,6 +490,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
|
||||
assert pos.price == Decimal('23.20')
|
||||
assert pos.tax_rate == Decimal('20.00')
|
||||
assert pos.tax_value == Decimal('3.87')
|
||||
t = o.transactions.get()
|
||||
assert t.price == Decimal('23.20')
|
||||
assert t.tax_rate == Decimal('20.00')
|
||||
assert t.tax_value == Decimal('3.87')
|
||||
|
||||
def test_country_taxing_free_price_and_voucher(self):
|
||||
self._enable_country_specific_taxing()
|
||||
|
||||
Reference in New Issue
Block a user