From 08d8e4dcc477b8be19232cc5aa366c490d7b67df Mon Sep 17 00:00:00 2001 From: Kara Engelhardt Date: Wed, 1 Apr 2026 14:03:49 +0200 Subject: [PATCH] Centralise order filter logic, properly handle cancelled orders in sendmail (Z#23230149) --- src/pretix/base/models/orders.py | 186 +++++++++++++++++++- src/pretix/control/forms/filter.py | 148 +--------------- src/pretix/plugins/sendmail/forms.py | 34 ++-- src/pretix/plugins/sendmail/views.py | 22 +-- src/tests/plugins/sendmail/test_sendmail.py | 121 +++++++++++++ 5 files changed, 328 insertions(+), 183 deletions(-) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index e74e450e8c..e1d84409f2 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -55,7 +55,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models, transaction from django.db.models import ( - Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, + Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, ) from django.db.models.functions import Coalesce, Greatest from django.db.models.signals import post_delete @@ -134,6 +134,124 @@ class OrderQuerySet(models.QuerySet): raise Order.DoesNotExist return order + def filter_by_status(self, qs, status: list[str]): + from .invoices import Invoice + + if not isinstance(status, list): + raise ValueError("`status` needs to be a list of strings") + + filter = Q() + + if any(s in status for s in ['overpaid', 'pendingpaid', 'partially_paid', 'underpaid']): + results = any(s in status for s in ['underpaid', 'overpaid']) + sums = any(s in status for s in ['pendingpaid', 'partially_paid']) + qs = Order.annotate_overpayments(qs, refunds=False, results=results, sums=sums) + + if 'o' in status: + filter |= Q(status=Order.STATUS_PENDING, expires__lt=now().replace(hour=0, minute=0, second=0)) + if 'np' in status: + filter |= Q(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) + if 'ne' in status: + filter |= Q(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED]) + if 'pv' in status: + filter |= Q(status=Order.STATUS_PAID) | Q(status=Order.STATUS_PENDING, valid_if_pending=True) + for s in ('p', 'n', 'e', 'c', 'r'): + if s in status: + filter |= Q(status=s) + if 'overpaid' in status: + qs = Order.annotate_overpayments(qs, refunds=False, results=True, sums=False) + filter |= Q(is_overpaid=True) + if 'rc' in status: + filter |= Q(cancellation_requests__isnull=False) + qs = qs.annotate( + cancellation_request_time=Max('cancellation_requests__created') + ).order_by( + '-cancellation_request_time' + ) + if 'pendingpaid' in status: + filter |= ( + Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING), + pending_sum_t__lte=0, + require_approval=False) + ) + if 'pendingnopayment' in status: + filter |= Q( + ~Exists( + OrderPayment.objects.filter( + order=OuterRef('pk'), + state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING) + ) + ), + status=Order.STATUS_PENDING, + require_approval=False, + ) + if 'partially_paid' in status: + filter |= ( + Q( + computed_payment_refund_sum__lt=F('total'), + computed_payment_refund_sum__gt=Decimal('0.00') + ) & ~Q( + status=Order.STATUS_CANCELED + ) + ) + if 'underpaid' in status: + filter |= Q(is_underpaid=True) + if 'cni' in status: + i = Invoice.objects.filter( + order=OuterRef('pk'), + is_cancellation=False, + refered__isnull=True, + ).order_by().values('order').annotate(k=Count('id')).values('k') + qs = qs.annotate( + icnt=i + ) + filter |= Q( + icnt__gt=0, + status=Order.STATUS_CANCELED, + ) + if 'pa' in status: + filter |= Q( + status=Order.STATUS_PENDING, + require_approval=True + ) + if 'na' in status: + filter |= Q( + status=Order.STATUS_PENDING, + require_approval=False + ) + if 'valid_if_confirmed' in status: + filter |= Q( + status=Order.STATUS_PENDING, + require_approval=False, + valid_if_pending=True + ) + if 'custom_followup_at' in status: + filter |= Q( + custom_followup_at__isnull=False + ) + if 'custom_followup_due' in status: + filter |= Q( + custom_followup_at__lte=now().astimezone(get_current_timezone()).date() + ) + if 'testmode' in status: + filter |= Q(testmode=True) + if 'cp' in status: + has_pc = OrderPosition.objects.filter( + order=OuterRef('pk') + ) + filter |= ( + Q(~Exists(has_pc), status=Order.STATUS_PAID) + | Q(status=Order.STATUS_CANCELED) + ) + if 'cany' in status: + has_pc_c = OrderPosition.all.filter( + order=OuterRef('pk'), + canceled=True, + ) + filter |= Q(Exists(has_pc_c)) | Q(status=Order.STATUS_CANCELED) + + return qs.filter(filter) + class Order(LockModel, LoggedModel): """ @@ -204,6 +322,69 @@ class Order(LockModel, LoggedModel): (STATUS_CANCELED, _("canceled")), ) + STATUS_FILTERS = ( + ('', _('All orders')), + (_('Valid orders'), ( + (STATUS_PAID, _('Paid (or canceled with paid fee)')), + (STATUS_PAID + 'v', _('Paid or confirmed')), + (STATUS_PENDING, _('Pending')), + (STATUS_PENDING + STATUS_PAID, _('Pending or paid')), + ('valid_if_confirmed', _('Pending but already confirmed')), + )), + (_('Cancellations'), ( + (STATUS_CANCELED, _('Canceled (fully)')), + ('cp', _('Canceled (fully or with paid fee)')), + ('cany', _('Canceled (at least one position)')), + ('rc', _('Cancellation requested')), + ('cni', _('Fully canceled but invoice not canceled')), + )), + (_('Payment process'), ( + (STATUS_EXPIRED, _('Expired')), + (STATUS_PENDING + STATUS_EXPIRED, _('Pending or expired')), + ('o', _('Pending (overdue)')), + ('overpaid', _('Overpaid')), + ('partially_paid', _('Partially paid')), + ('underpaid', _('Underpaid (but confirmed)')), + ('pendingpaid', _('Pending (but fully paid)')), + ('pendingnopayment', _('Pending (but no current payment)')), + )), + (_('Approval process'), ( + ('na', _('Approved, payment pending')), + ('pa', _('Approval pending')), + )), + (_('Follow-up date'), ( + ('custom_followup_at', _('Follow-up configured')), + ('custom_followup_due', _('Follow-up due')), + )), + ('testmode', _('Test mode')), + ) + + STATUS_FILTER_OPTIONS = ( + (STATUS_PAID, _('Paid (or canceled with paid fee)')), + (STATUS_PAID + 'v', _('Paid or confirmed')), + ('valid_if_confirmed', _('Pending but already confirmed')), + (STATUS_PENDING, _('Pending')), + (STATUS_PENDING + STATUS_PAID, _('Pending or paid')), + (STATUS_CANCELED, _('Canceled (fully)')), + ('cp', _('Canceled (fully or with paid fee)')), + ('cany', _('Canceled (at least one position)')), + ('rc', _('Cancellation requested')), + ('cni', _('Fully canceled but invoice not canceled')), + (STATUS_EXPIRED, _('Expired')), + (STATUS_PENDING + STATUS_EXPIRED, _('Pending or expired')), + ('o', _('Pending (overdue)')), + ('overpaid', _('Overpaid')), + ('partially_paid', _('Partially paid')), + ('underpaid', _('Underpaid (but confirmed)')), + ('pendingpaid', _('Pending (but fully paid)')), + ('pendingnopayment', _('Pending (but no current payment)')), + ('na', _('Approved, payment pending')), + ('pa', _('Approval pending')), + ('custom_followup_at', _('Follow-up configured')), + ('custom_followup_due', _('Follow-up due')), + ('testmode', _('Test mode')), + ) + code = models.CharField( max_length=16, verbose_name=_("Order code"), @@ -2282,7 +2463,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model): :type value: Decimal :param order: Order this fee is charged with :type order: Order - :param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``giftcard``, or ``other``. + :param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``cancellation``, ``insurance``, ``late``, + ``giftcard``, or ``other``. :type fee_type: str :param description: A human-readable description of the fee :type description: str diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 806c5e2ea2..fcdae7f2c1 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -40,7 +40,7 @@ from django import forms from django.apps import apps from django.conf import settings from django.db.models import ( - Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet, + Count, Exists, F, Model, OrderBy, OuterRef, Q, QuerySet, ) from django.db.models.functions import Coalesce, ExtractWeekDay, Upper from django.urls import reverse, reverse_lazy @@ -219,41 +219,7 @@ class OrderFilterForm(FilterForm): ) status = forms.ChoiceField( label=_('Order status'), - choices=( - ('', _('All orders')), - (_('Valid orders'), ( - (Order.STATUS_PAID, _('Paid (or canceled with paid fee)')), - (Order.STATUS_PAID + 'v', _('Paid or confirmed')), - (Order.STATUS_PENDING, _('Pending')), - (Order.STATUS_PENDING + Order.STATUS_PAID, _('Pending or paid')), - )), - (_('Cancellations'), ( - (Order.STATUS_CANCELED, _('Canceled (fully)')), - ('cp', _('Canceled (fully or with paid fee)')), - ('cany', _('Canceled (at least one position)')), - ('rc', _('Cancellation requested')), - ('cni', _('Fully canceled but invoice not canceled')), - )), - (_('Payment process'), ( - (Order.STATUS_EXPIRED, _('Expired')), - (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _('Pending or expired')), - ('o', _('Pending (overdue)')), - ('overpaid', _('Overpaid')), - ('partially_paid', _('Partially paid')), - ('underpaid', _('Underpaid (but confirmed)')), - ('pendingpaid', _('Pending (but fully paid)')), - ('pendingnopayment', _('Pending (but no current payment)')), - )), - (_('Approval process'), ( - ('na', _('Approved, payment pending')), - ('pa', _('Approval pending')), - )), - (_('Follow-up date'), ( - ('custom_followup_at', _('Follow-up configured')), - ('custom_followup_due', _('Follow-up due')), - )), - ('testmode', _('Test mode')), - ), + choices=Order.STATUS_FILTERS, required=False, ) @@ -309,114 +275,8 @@ class OrderFilterForm(FilterForm): mainq ) - if fdata.get('status'): - s = fdata.get('status') - if s == 'o': - qs = qs.filter(status=Order.STATUS_PENDING, expires__lt=now().replace(hour=0, minute=0, second=0)) - elif s == 'np': - qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) - elif s == 'ne': - qs = qs.filter(status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED]) - elif s == 'pv': - qs = qs.filter(Q(status=Order.STATUS_PAID) | Q(status=Order.STATUS_PENDING, valid_if_pending=True)) - elif s in ('p', 'n', 'e', 'c', 'r'): - qs = qs.filter(status=s) - elif s == 'overpaid': - qs = Order.annotate_overpayments(qs, refunds=False, results=True, sums=False) - qs = qs.filter(is_overpaid=True) - elif s == 'rc': - qs = qs.filter( - cancellation_requests__isnull=False - ).annotate( - cancellation_request_time=Max('cancellation_requests__created') - ).order_by( - '-cancellation_request_time' - ) - elif s == '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 s == 'pendingnopayment': - qs = qs.exclude( - Exists( - OrderPayment.objects.filter( - order=OuterRef('pk'), - state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING) - ) - ) - ).filter( - status=Order.STATUS_PENDING, - require_approval=False, - ) - elif s == 'partially_paid': - qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True) - qs = qs.filter( - computed_payment_refund_sum__lt=F('total'), - computed_payment_refund_sum__gt=Decimal('0.00') - ).exclude( - status=Order.STATUS_CANCELED - ) - elif s == 'underpaid': - qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True) - qs = qs.filter( - Q(status=Order.STATUS_PAID, pending_sum_t__gt=0) | - Q(status=Order.STATUS_CANCELED, pending_sum_rc__gt=0) - ) - elif s == 'cni': - i = Invoice.objects.filter( - order=OuterRef('pk'), - is_cancellation=False, - refered__isnull=True, - ).order_by().values('order').annotate(k=Count('id')).values('k') - qs = qs.annotate( - icnt=i - ).filter( - icnt__gt=0, - status=Order.STATUS_CANCELED, - ) - elif s == 'pa': - qs = qs.filter( - status=Order.STATUS_PENDING, - require_approval=True - ) - elif s == 'na': - qs = qs.filter( - status=Order.STATUS_PENDING, - require_approval=False - ) - elif s == 'custom_followup_at': - qs = qs.filter( - custom_followup_at__isnull=False - ) - elif s == 'custom_followup_due': - qs = qs.filter( - custom_followup_at__lte=now().astimezone(get_current_timezone()).date() - ) - elif s == 'testmode': - qs = qs.filter( - testmode=True - ) - elif s == 'cp': - s = OrderPosition.objects.filter( - order=OuterRef('pk') - ) - qs = qs.annotate( - has_pc=Exists(s) - ).filter( - Q(status=Order.STATUS_PAID, has_pc=False) | Q(status=Order.STATUS_CANCELED) - ) - elif s == 'cany': - s = OrderPosition.all.filter( - order=OuterRef('pk'), - canceled=True, - ) - qs = qs.annotate( - has_pc_c=Exists(s) - ).filter( - Q(has_pc_c=True) | Q(status=Order.STATUS_CANCELED) - ) + if s := fdata.get('status'): + qs = qs.filter_by_status(qs, [s]) if fdata.get('ordering'): qs = qs.order_by(*get_deterministic_ordering(Order, self.get_order_by())) diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py index 060bd5eea6..613af0d0b4 100644 --- a/src/pretix/plugins/sendmail/forms.py +++ b/src/pretix/plugins/sendmail/forms.py @@ -151,7 +151,13 @@ class OrderMailForm(BaseMailForm): initial='orders', choices=[] ) - sendto = forms.MultipleChoiceField() # overridden later + sendto = forms.MultipleChoiceField( + label=pgettext_lazy('sendmail_form', 'Restrict to orders with status'), + widget=forms.CheckboxSelectMultiple( + attrs={'class': 'scrolling-multiple-choice no-search'} + ), + choices=Order.STATUS_FILTER_OPTIONS + ) items = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple( attrs={'class': 'scrolling-multiple-choice'} @@ -230,27 +236,15 @@ class OrderMailForm(BaseMailForm): self.fields['attach_tickets'].help_text = _("Attachment of tickets is disabled in this event's email " "settings.") - choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n'] - choices.insert(0, ('valid_if_pending', _('payment pending but already confirmed'))) - choices.insert(0, ('na', _('payment pending (except unapproved or already confirmed)'))) - choices.insert(0, ('pa', _('approval pending'))) - if not event.settings.get('payment_term_expire_automatically', as_type=bool): - choices.append( - ('overdue', _('pending with payment overdue')) - ) - self.fields['sendto'] = forms.MultipleChoiceField( - label=pgettext_lazy('sendmail_form', 'Restrict to orders with status'), - widget=forms.CheckboxSelectMultiple( - attrs={'class': 'scrolling-multiple-choice no-search'} - ), - choices=choices - ) if not self.initial.get('sendto'): self.initial['sendto'] = ['p', 'valid_if_pending'] - elif 'n' in self.initial['sendto']: - self.initial['sendto'].append('pa') - self.initial['sendto'].append('na') - self.initial['sendto'].append('valid_if_pending') + else: + if 'n' in self.initial['sendto']: + self.initial['sendto'].append('pa') + self.initial['sendto'].append('na') + self.initial['sendto'].append('valid_if_pending') + if 'overdue' in self.initial['sendto']: + self.initial['sendto'].append('o') self.fields['items'].queryset = event.items.all() if not self.initial.get('items'): diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index a78edbb9c4..bef6120c66 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -46,7 +46,6 @@ from django.shortcuts import get_object_or_404, redirect from django.template.loader import get_template from django.urls import reverse from django.utils.functional import cached_property -from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, ngettext from django.views.generic import DeleteView, FormView, ListView, TemplateView @@ -262,11 +261,8 @@ class OrderSendView(BaseSenderView): i.pk: str(i) for i in logentry.event.checkin_lists.all() } if 'status' not in _cache_store: - status = dict(Order.STATUS_CHOICE) - status['overdue'] = _('pending with payment overdue') - status['valid_if_pending'] = _('payment pending but already confirmed') - status['na'] = _('payment pending (except unapproved or already confirmed)') - status['pa'] = _('approval pending') + status = dict(Order.STATUS_FILTER_OPTIONS) + status['overdue'] = status['o'] status['r'] = status['c'] _cache_store['status'] = status @@ -343,16 +339,7 @@ class OrderSendView(BaseSenderView): def get_object_queryset(self, form): qs = Order.objects.filter(event=self.request.event) - statusq = Q(status__in=form.cleaned_data['sendto']) - if 'overdue' in form.cleaned_data['sendto']: - statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=False, expires__lt=now()) - if 'pa' in form.cleaned_data['sendto']: - statusq |= Q(status=Order.STATUS_PENDING, require_approval=True) - if 'na' in form.cleaned_data['sendto']: - statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=False) - if 'valid_if_pending' in form.cleaned_data['sendto']: - statusq |= Q(status=Order.STATUS_PENDING, require_approval=False, valid_if_pending=True) - orders = qs.filter(statusq) + orders = qs.filter_by_status(qs, form.cleaned_data['sendto']) opq = OrderPosition.objects.filter( Q(item_id__in=[i.pk for i in form.cleaned_data.get('items')]) | Q(Exists( @@ -408,9 +395,10 @@ class OrderSendView(BaseSenderView): if form.cleaned_data.get('created_to'): opq = opq.filter(order__datetime__lt=form.cleaned_data.get('created_to')) + orders_without_positions = Order.objects.filter(~Exists(OrderPosition.objects.filter(canceled=False, order_id=OuterRef('pk')))) # pk__in turns out to be faster than Exists(subquery) in many cases since we often filter on a large subset # of orderpositions - return orders.filter(pk__in=opq.values_list('order_id')) + return orders.filter(Q(pk__in=opq.values_list('order_id')) | Q(pk__in=orders_without_positions)) def describe_match_size(self, cnt): return ngettext( diff --git a/src/tests/plugins/sendmail/test_sendmail.py b/src/tests/plugins/sendmail/test_sendmail.py index c971291d03..9b0e781a00 100644 --- a/src/tests/plugins/sendmail/test_sendmail.py +++ b/src/tests/plugins/sendmail/test_sendmail.py @@ -41,6 +41,7 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pretix.base.models import Checkin, Item, Order, OrderPosition, Team, User +from pretix.base.models.orders import OrderFee @pytest.fixture @@ -615,3 +616,123 @@ def test_waitinglist_sendmail_simple_case(logged_in_client, sendmail_url, event, assert response.status_code == 200 assert 'Test subject' in response.rendered_content + + +@pytest.mark.django_db +def test_sendmail_canceled(logged_in_client, sendmail_url, event, item): + event.settings.attendee_emails_asked = True + with scopes_disabled(): + o1 = Order.objects.create(event=event, status=Order.STATUS_CANCELED, + expires=now() + datetime.timedelta(hours=1), + total=13, code='DUMMY1', email='order1@dummy.test', + datetime=now(), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + locale='de') + o1_p1 = OrderPosition.objects.create(order=o1, item=item, price=13, attendee_email='attendee1@dummy.test') + o2 = Order.objects.create(event=event, status=Order.STATUS_PAID, + expires=now() + datetime.timedelta(hours=1), + total=2, code='DUMMY2', email='order2@dummy.test', + datetime=now(), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + locale='de') + OrderPosition.objects.create(order=o2, item=item, price=13, canceled=True, attendee_email='attendee2@dummy.test') + OrderFee.objects.create(order=o2, fee_type=OrderFee.FEE_TYPE_CANCELLATION, value=2) + o3 = Order.objects.create(event=event, status=Order.STATUS_PAID, + expires=now() + datetime.timedelta(hours=1), + total=13, code='DUMMY3', email='order3@dummy.test', + datetime=now(), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + locale='de') + o3_p1 = OrderPosition.objects.create(order=o3, item=item, price=13, attendee_email='attendee3@dummy.test') + OrderPosition.objects.create(order=o3, item=item, price=13, canceled=True, attendee_email='attendee4@dummy.test') + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'c', + 'action': 'send', + 'recipients': 'orders', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 1 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1.email,), } + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'cp', + 'action': 'send', + 'recipients': 'orders', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 2 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1.email,), (o2.email,)} + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'cany', + 'action': 'send', + 'recipients': 'orders', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 3 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1.email,), (o2.email,), (o3.email,)} + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'c', + 'action': 'send', + 'recipients': 'attendees', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 1 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1_p1.attendee_email,), } + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'cp', + 'action': 'send', + 'recipients': 'attendees', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + # o2 has no active attendees + assert len(djmail.outbox) == 1 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1_p1.attendee_email,), } + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url + 'orders/', + {'sendto': 'cany', + 'action': 'send', + 'recipients': 'attendees', + 'items': item.pk, + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + }, + follow=True) + + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 2 + assert set(tuple(x.to) for x in djmail.outbox) == {(o1_p1.attendee_email,), (o3_p1.attendee_email,)}