Compare commits

..

3 Commits

Author SHA1 Message Date
Raphael Michel
d9d11883a1 Review notes 2025-02-21 15:39:29 +01:00
Raphael Michel
1acde9b8f9 Update src/pretix/base/models/log.py
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2025-02-11 15:40:42 +01:00
Raphael Michel
a9bf84b688 Improve efficiency of bulk operations 2025-02-11 10:56:37 +01:00
9 changed files with 102 additions and 168 deletions

View File

@@ -75,9 +75,8 @@ positions list of objects List of order p
fees list of objects List of fees included in the order total. By default, only fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included. non-canceled fees are included.
├ id integer Internal ID of the fee record ├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``shipping``, ├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``service``, ``cancellation``, ``insurance``, ``late``, ``other``)
``other``, ``giftcard``)
├ value money (string) Fee amount ├ value money (string) Fee amount
├ description string Human-readable string with more details (can be empty) ├ description string Human-readable string with more details (can be empty)
├ internal_type string Internal string (i.e. ID of the payment provider), ├ internal_type string Internal string (i.e. ID of the payment provider),
@@ -2245,9 +2244,6 @@ otherwise, such as splitting an order or changing fees.
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID. * ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
* ``create_fees``: A list of objects describing new order fees with the fields ``fee_type``, ``value``, ``description``,
``internal_type``, ``tax_rule``
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice * ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null`` address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
(the default) the taxes are not recalculated. (the default) the taxes are not recalculated.
@@ -2267,12 +2263,17 @@ otherwise, such as splitting an order or changing fees.
Content-Type: application/json Content-Type: application/json
{ {
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [ "patch_positions": [
{ {
"position": 12374, "position": 12374,
"body": { "body": {
"item": 12, "item": 12,
"variation": null, "variation": None,
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
@@ -2280,11 +2281,6 @@ otherwise, such as splitting an order or changing fees.
} }
} }
], ],
"cancel_positions": [
{
"position": 12373
}
],
"split_positions": [ "split_positions": [
{ {
"position": 12375 "position": 12375
@@ -2293,7 +2289,7 @@ otherwise, such as splitting an order or changing fees.
"create_positions": [ "create_positions": [
{ {
"item": 12, "item": 12,
"variation": null, "variation": None,
"subevent": 562, "subevent": 562,
"seat": "seat-guid-2", "seat": "seat-guid-2",
"price": "99.99", "price": "99.99",
@@ -2301,26 +2297,17 @@ otherwise, such as splitting an order or changing fees.
"attendee_name": "Peter", "attendee_name": "Peter",
} }
], ],
"patch_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"cancel_fees": [ "cancel_fees": [
{ {
"fee": 49 "fee": 49
} }
], ],
"create_fees": [ "change_fees": [
{ {
"fee_type": "other", "fee": 51,
"value": "1.50", "body": {
"description": "Example Fee", "value": "12.00"
"internal_type": "", }
"tax_rule": 15
} }
], ],
"reissue_invoice": true, "reissue_invoice": true,

View File

@@ -30,7 +30,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField, AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderFeeCreateSerializer, OrderPositionCreateSerializer, OrderPositionCreateSerializer,
) )
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError from pretix.base.services.orders import OrderError
@@ -104,54 +104,6 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
raise ValidationError(str(e)) raise ValidationError(str(e))
class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
value = serializers.DecimalField(required=True, allow_null=False, decimal_places=2,
max_digits=13)
internal_type = serializers.CharField(required=False, default="")
class Meta:
model = OrderFee
fields = ('order', 'fee_type', 'value', 'description', 'internal_type', 'tax_rule')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if 'order' in self.context:
data['order'] = self.context['order']
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
f = OrderFee(
order=validated_data['order'],
fee_type=validated_data['fee_type'],
value=validated_data.get('value'),
description=validated_data.get('description'),
internal_type=validated_data.get('internal_type'),
tax_rule=validated_data.get('tax_rule'),
)
f._calculate_tax()
ocm.add_fee(f)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].fees.order_by('-pk').first()
else:
return OrderFee() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer): class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*') country = CompatibleCountryField(source='*')
@@ -449,9 +401,6 @@ class OrderChangeOperationSerializer(serializers.Serializer):
self.fields['split_positions'] = SelectPositionSerializer( self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context many=True, required=False, context=self.context
) )
self.fields['create_fees'] = OrderFeeCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer( self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context many=True, required=False, context=self.context
) )

View File

@@ -63,8 +63,7 @@ from pretix.api.serializers.order import (
) )
from pretix.api.serializers.orderchange import ( from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer, BlockNameSerializer, OrderChangeOperationSerializer,
OrderFeeChangeSerializer, OrderFeeCreateForExistingOrderSerializer, OrderFeeChangeSerializer, OrderPositionChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer, OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer, OrderPositionInfoPatchSerializer,
) )
@@ -989,12 +988,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
ocm.cancel_fee(r['fee']) ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee']) canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('create_fees', []):
pos_serializer = OrderFeeCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
for r in serializer.validated_data.get('patch_fees', []): for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees: if r['fee'] in canceled_fees:
continue continue

View File

@@ -37,7 +37,7 @@ import logging
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import connections, models
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pretix.base.logentrytype_registry import log_entry_types, make_link from pretix.base.logentrytype_registry import log_entry_types, make_link
@@ -165,6 +165,15 @@ class LogEntry(models.Model):
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
raise TypeError("Logs cannot be deleted.") raise TypeError("Logs cannot be deleted.")
@classmethod
def bulk_create_and_postprocess(cls, objects):
if connections['default'].features.can_return_rows_from_bulk_insert:
cls.objects.bulk_create(objects)
else:
for le in objects:
le.save()
cls.bulk_postprocess(objects)
@classmethod @classmethod
def bulk_postprocess(cls, objects): def bulk_postprocess(cls, objects):
from pretix.api.webhooks import notify_webhooks from pretix.api.webhooks import notify_webhooks

View File

@@ -46,7 +46,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File from django.core.files import File
from django.db import connections, transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch, Count, Exists, F, IntegerField, Max, Min, OuterRef, Prefetch,
ProtectedError, Q, Subquery, Sum, ProtectedError, Q, Subquery, Sum,
@@ -1159,13 +1159,7 @@ class DeviceBulkUpdateView(DeviceQueryMixin, OrganizerDetailViewMixin, Organizer
obj.log_action('pretix.device.changed', data=data, user=self.request.user, save=False) obj.log_action('pretix.device.changed', data=data, user=self.request.user, save=False)
) )
if connections['default'].features.can_return_rows_from_bulk_insert: LogEntry.bulk_create_and_postprocess(log_entries)
LogEntry.objects.bulk_create(log_entries, batch_size=200)
LogEntry.bulk_postprocess(log_entries)
else:
for le in log_entries:
le.save()
LogEntry.bulk_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form) return super().form_valid(form)

View File

@@ -40,7 +40,7 @@ from dateutil.rrule import rruleset
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files import File from django.core.files import File
from django.db import connections, transaction from django.db import transaction
from django.db.models import Count, F, Prefetch, ProtectedError from django.db.models import Count, F, Prefetch, ProtectedError
from django.db.models.functions import Coalesce, TruncDate, TruncTime from django.db.models.functions import Coalesce, TruncDate, TruncTime
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
@@ -657,24 +657,30 @@ class SubEventBulkAction(SubEventQueryMixin, EventPermissionRequiredMixin, View)
@transaction.atomic @transaction.atomic
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'disable': if request.POST.get('action') == 'disable':
log_entries = []
for obj in self.get_queryset(): for obj in self.get_queryset():
obj.log_action( log_entries.append(obj.log_action(
'pretix.subevent.changed', user=self.request.user, data={ 'pretix.subevent.changed', user=self.request.user, data={
'active': False 'active': False
} }, save=False
) ))
obj.active = False obj.active = False
obj.save(update_fields=['active']) obj.save(update_fields=['active'])
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, pgettext_lazy('subevent', 'The selected dates have been disabled.')) messages.success(request, pgettext_lazy('subevent', 'The selected dates have been disabled.'))
elif request.POST.get('action') == 'enable': elif request.POST.get('action') == 'enable':
log_entries = []
for obj in self.get_queryset(): for obj in self.get_queryset():
obj.log_action( log_entries.append(obj.log_action(
'pretix.subevent.changed', user=self.request.user, data={ 'pretix.subevent.changed', user=self.request.user, data={
'active': True 'active': True
} }, save=False
) ))
obj.active = True obj.active = True
obj.save(update_fields=['active']) obj.save(update_fields=['active'])
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, pgettext_lazy('subevent', 'The selected dates have been enabled.')) messages.success(request, pgettext_lazy('subevent', 'The selected dates have been enabled.'))
elif request.POST.get('action') == 'delete': elif request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/subevents/delete_bulk.html', { return render(request, 'pretixcontrol/subevents/delete_bulk.html', {
@@ -682,22 +688,28 @@ class SubEventBulkAction(SubEventQueryMixin, EventPermissionRequiredMixin, View)
'forbidden': self.get_queryset().filter(orderposition__isnull=False).distinct(), 'forbidden': self.get_queryset().filter(orderposition__isnull=False).distinct(),
}) })
elif request.POST.get('action') == 'delete_confirm': elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.get_queryset(): for obj in self.get_queryset():
try: try:
if not obj.allow_delete(): if not obj.allow_delete():
raise ProtectedError('only deactivate', [obj]) raise ProtectedError('only deactivate', [obj])
CartPosition.objects.filter(addon_to__subevent=obj).delete() log_entries.append(obj.log_action('pretix.subevent.deleted', user=self.request.user, save=False))
obj.cartposition_set.all().delete() to_delete.append(obj.pk)
obj.log_action('pretix.subevent.deleted', user=self.request.user)
obj.delete()
except ProtectedError: except ProtectedError:
obj.log_action( log_entries.append(obj.log_action(
'pretix.subevent.changed', user=self.request.user, data={ 'pretix.subevent.changed', user=self.request.user, data={
'active': False 'active': False
} }, save=False,
) ))
obj.active = False obj.active = False
obj.save(update_fields=['active']) obj.save(update_fields=['active'])
if to_delete:
CartPosition.objects.filter(addon_to__subevent_id__in=to_delete).delete()
CartPosition.objects.filter(subevent_id__in=to_delete).delete()
SubEvent.objects.filter(pk__in=to_delete).delete()
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, pgettext_lazy('subevent', 'The selected dates have been deleted or disabled.')) messages.success(request, pgettext_lazy('subevent', 'The selected dates have been deleted or disabled.'))
return redirect(self.get_success_url()) return redirect(self.get_success_url())
@@ -1009,13 +1021,7 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
f.save() f.save()
set_progress(90) set_progress(90)
if connections['default'].features.can_return_rows_from_bulk_insert: LogEntry.bulk_create_and_postprocess(log_entries)
LogEntry.objects.bulk_create(log_entries)
LogEntry.bulk_postprocess(log_entries)
else:
for le in log_entries:
le.save()
LogEntry.bulk_postprocess(log_entries)
self.request.event.cache.clear() self.request.event.cache.clear()
return len(subevents) return len(subevents)
@@ -1578,13 +1584,7 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
self.save_itemvars() self.save_itemvars()
self.save_meta() self.save_meta()
if connections['default'].features.can_return_rows_from_bulk_insert: LogEntry.bulk_create_and_postprocess(log_entries)
LogEntry.objects.bulk_create(log_entries, batch_size=200)
LogEntry.bulk_postprocess(log_entries)
else:
for le in log_entries:
le.save()
LogEntry.bulk_postprocess(log_entries)
self.request.event.cache.clear() self.request.event.cache.clear()
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))

View File

@@ -477,7 +477,7 @@ class VoucherBulkCreate(EventPermissionRequiredMixin, AsyncFormView):
log_entries.append( log_entries.append(
v.log_action('pretix.voucher.added', data=data, user=self.request.user, save=False) v.log_action('pretix.voucher.added', data=data, user=self.request.user, save=False)
) )
LogEntry.objects.bulk_create(log_entries) LogEntry.bulk_create_and_postprocess(log_entries)
form.post_bulk_save(batch_vouchers) form.post_bulk_save(batch_vouchers)
batch_vouchers.clear() batch_vouchers.clear()
set_progress(len(voucherids) / total_num * (50. if form.cleaned_data['send'] else 100.)) set_progress(len(voucherids) / total_num * (50. if form.cleaned_data['send'] else 100.))
@@ -619,19 +619,26 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
'forbidden': self.objects.exclude(redeemed=0), 'forbidden': self.objects.exclude(redeemed=0),
}) })
elif request.POST.get('action') == 'delete_confirm': elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.objects: for obj in self.objects:
if obj.allow_delete(): if obj.allow_delete():
obj.log_action('pretix.voucher.deleted', user=self.request.user) log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False))
CartPosition.objects.filter(addon_to__voucher=obj).delete() to_delete.append(obj.pk)
obj.cartposition_set.all().delete()
obj.delete()
else: else:
obj.log_action('pretix.voucher.changed', user=self.request.user, data={ log_entries.append(obj.log_action('pretix.voucher.changed', user=self.request.user, data={
'max_usages': min(obj.redeemed, obj.max_usages), 'max_usages': min(obj.redeemed, obj.max_usages),
'bulk': True 'bulk': True
}) }), save=False)
obj.max_usages = min(obj.redeemed, obj.max_usages) obj.max_usages = min(obj.redeemed, obj.max_usages)
obj.save(update_fields=['max_usages']) obj.save(update_fields=['max_usages'])
if to_delete:
CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete()
CartPosition.objects.filter(voucher_id__in=to_delete).delete()
Voucher.objects.filter(pk__in=to_delete).delete()
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, _('The selected vouchers have been deleted or disabled.')) messages.success(request, _('The selected vouchers have been deleted or disabled.'))
return redirect(self.get_success_url()) return redirect(self.get_success_url())

View File

@@ -49,7 +49,7 @@ from django.utils.translation import gettext_lazy as _, pgettext
from django.views import View from django.views import View
from django.views.generic import ListView from django.views.generic import ListView
from pretix.base.models import Item, Quota, WaitingListEntry from pretix.base.models import Item, LogEntry, Quota, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.waitinglist import assign_automatically from pretix.base.services.waitinglist import assign_automatically
from pretix.base.views.tasks import AsyncAction from pretix.base.views.tasks import AsyncAction
@@ -160,10 +160,15 @@ class WaitingListActionView(EventPermissionRequiredMixin, WaitingListQuerySetMix
'forbidden': self.get_queryset().filter(voucher__isnull=False), 'forbidden': self.get_queryset().filter(voucher__isnull=False),
}) })
elif request.POST.get('action') == 'delete_confirm': elif request.POST.get('action') == 'delete_confirm':
for obj in self.get_queryset(force_filtered=True): with transaction.atomic():
if not obj.voucher_id: log_entries = []
obj.log_action('pretix.event.orders.waitinglist.deleted', user=self.request.user) to_delete = []
obj.delete() for obj in self.get_queryset(force_filtered=True):
if not obj.voucher_id:
log_entries.append(obj.log_action('pretix.event.orders.waitinglist.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
WaitingListEntry.objects.filter(id__in=to_delete).delete()
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, _('The selected entries have been deleted.')) messages.success(request, _('The selected entries have been deleted.'))
return self._redirect_back() return self._redirect_back()
@@ -186,16 +191,17 @@ class WaitingListActionView(EventPermissionRequiredMixin, WaitingListQuerySetMix
if 'move_top' in request.POST: if 'move_top' in request.POST:
try: try:
wle = WaitingListEntry.objects.get( with transaction.atomic():
pk=request.POST.get('move_top'), event=self.request.event, wle = WaitingListEntry.objects.get(
) pk=request.POST.get('move_top'), event=self.request.event,
wle.priority = self.request.event.waitinglistentries.aggregate(m=Max('priority'))['m'] + 1 )
wle.save(update_fields=['priority']) wle.priority = self.request.event.waitinglistentries.aggregate(m=Max('priority'))['m'] + 1
wle.log_action( wle.save(update_fields=['priority'])
'pretix.event.orders.waitinglist.changed', wle.log_action(
data={'priority': wle.priority}, 'pretix.event.orders.waitinglist.changed',
user=self.request.user, data={'priority': wle.priority},
) user=self.request.user,
)
messages.success(request, _('The waiting list entry has been moved to the top.')) messages.success(request, _('The waiting list entry has been moved to the top.'))
return self._redirect_back() return self._redirect_back()
except WaitingListEntry.DoesNotExist: except WaitingListEntry.DoesNotExist:
@@ -204,16 +210,17 @@ class WaitingListActionView(EventPermissionRequiredMixin, WaitingListQuerySetMix
if 'move_end' in request.POST: if 'move_end' in request.POST:
try: try:
wle = WaitingListEntry.objects.get( with transaction.atomic():
pk=request.POST.get('move_end'), event=self.request.event, wle = WaitingListEntry.objects.get(
) pk=request.POST.get('move_end'), event=self.request.event,
wle.priority = self.request.event.waitinglistentries.aggregate(m=Min('priority'))['m'] - 1 )
wle.save(update_fields=['priority']) wle.priority = self.request.event.waitinglistentries.aggregate(m=Min('priority'))['m'] - 1
wle.log_action( wle.save(update_fields=['priority'])
'pretix.event.orders.waitinglist.changed', wle.log_action(
data={'priority': wle.priority}, 'pretix.event.orders.waitinglist.changed',
user=self.request.user, data={'priority': wle.priority},
) user=self.request.user,
)
messages.success(request, _('The waiting list entry has been moved to the end of the list.')) messages.success(request, _('The waiting list entry has been moved to the end of the list.'))
return self._redirect_back() return self._redirect_back()
except WaitingListEntry.DoesNotExist: except WaitingListEntry.DoesNotExist:

View File

@@ -1797,13 +1797,6 @@ def test_order_change_cancel_and_create(token_client, organizer, event, order, q
'price': '99.99' 'price': '99.99'
}, },
], ],
'create_fees': [
{
'value': '5.99',
'fee_type': 'service',
'description': 'Service fee',
},
],
'cancel_fees': [ 'cancel_fees': [
{ {
'fee': f.pk, 'fee': f.pk,
@@ -1825,11 +1818,6 @@ def test_order_change_cancel_and_create(token_client, organizer, event, order, q
assert p_new.price == Decimal('99.99') assert p_new.price == Decimal('99.99')
f.refresh_from_db() f.refresh_from_db()
assert f.canceled assert f.canceled
f_new = order.all_fees.get(fee_type=OrderFee.FEE_TYPE_SERVICE)
assert f_new.value == Decimal('5.99')
assert f_new.description == "Service fee"
assert f_new.internal_type == ""
assert f_new.tax_value == Decimal('0.00')
@pytest.mark.django_db @pytest.mark.django_db