diff --git a/doc/api/resources/sendmail_rules.rst b/doc/api/resources/sendmail_rules.rst index 40597be9c2..8c688aaaa4 100644 --- a/doc/api/resources/sendmail_rules.rst +++ b/doc/api/resources/sendmail_rules.rst @@ -23,10 +23,14 @@ limit_products list of integers List of product restrict_to_status list List of order states to restrict recipients to. Valid entries are ``p`` for paid, ``e`` for expired, ``c`` for canceled, ``n__pending_approval`` for pending approval, - ``n__not_pending_approval_and_not_valid_if_pending`` for payment pending, - ``n__valid_if_pending`` for payment pending but already confirmed, + ``n__not_pending_approval_and_not_valid_if_pending`` for payment + pending, ``n__valid_if_pending`` for payment pending but already confirmed, and ``n__pending_overdue`` for pending with payment overdue. The default is ``["p", "n__valid_if_pending"]``. +checked_in_status string Check-in status to restrict recipients to. Valid strings are: + ``null`` for no filtering (default), ``checked_in`` for + limiting to attendees that are or have been checked in, and + ``no_checkin`` for limiting to attendees who have not checked in. date_is_absolute boolean If ``true``, the email is set at a specific point in time. send_date datetime If ``date_is_absolute`` is set: Date and time to send the email. send_offset_days integer If ``date_is_absolute`` is not set, this is the number of days @@ -89,6 +93,7 @@ Endpoints "n__not_pending_approval_and_not_valid_if_pending", "n__valid_if_pending" ], + "checked_in_status": null, "send_date": null, "send_offset_days": 1, "send_offset_time": "18:00", @@ -139,6 +144,7 @@ Endpoints "n__not_pending_approval_and_not_valid_if_pending", "n__valid_if_pending" ], + "checked_in_status": null, "send_date": null, "send_offset_days": 1, "send_offset_time": "18:00", @@ -180,6 +186,7 @@ Endpoints "n__not_pending_approval_and_not_valid_if_pending", "n__valid_if_pending" ], + "checked_in_status": "checked_in", "send_date": null, "send_offset_days": 1, "send_offset_time": "18:00", @@ -209,6 +216,7 @@ Endpoints "n__not_pending_approval_and_not_valid_if_pending", "n__valid_if_pending" ], + "checked_in_status": "checked_in", "send_date": null, "send_offset_days": 1, "send_offset_time": "18:00", @@ -266,6 +274,7 @@ Endpoints "n__not_pending_approval_and_not_valid_if_pending", "n__valid_if_pending" ], + "checked_in_status": "checked_in", "send_date": null, "send_offset_days": 1, "send_offset_time": "18:00", diff --git a/src/pretix/plugins/sendmail/api.py b/src/pretix/plugins/sendmail/api.py index f3a31bab39..1bb0baec08 100644 --- a/src/pretix/plugins/sendmail/api.py +++ b/src/pretix/plugins/sendmail/api.py @@ -34,7 +34,7 @@ class RuleSerializer(I18nAwareModelSerializer): class Meta: model = Rule fields = ['id', 'subject', 'template', 'all_products', 'limit_products', 'restrict_to_status', - 'send_date', 'send_offset_days', 'send_offset_time', 'date_is_absolute', + 'checked_in_status', 'send_date', 'send_offset_days', 'send_offset_time', 'date_is_absolute', 'offset_to_event_end', 'offset_is_after', 'send_to', 'enabled'] read_only_fields = ['id'] @@ -88,6 +88,10 @@ class RuleSerializer(I18nAwareModelSerializer): ]: raise ValidationError(f'status {s} not allowed: restrict_to_status may only include valid states') + if full_data.get('checked_in_status') == "": + # even though "blank" is not allowed on this field, "" gets accepted without this check + raise ValidationError('empty string not allowed: use null to disable check-in based filtering') + return full_data def save(self, **kwargs): diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py index 8fc3b78382..e407df6fb0 100644 --- a/src/pretix/plugins/sendmail/forms.py +++ b/src/pretix/plugins/sendmail/forms.py @@ -312,7 +312,7 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm): fields = ['subject', 'template', 'attach_ical', 'send_date', 'send_offset_days', 'send_offset_time', 'all_products', 'limit_products', 'restrict_to_status', - 'send_to', 'enabled'] + 'checked_in_status', 'send_to', 'enabled'] field_classes = { 'subevent': SafeModelMultipleChoiceField, @@ -337,6 +337,7 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm): 'data-inverse-dependency': '#id_all_products'}, ), 'send_to': forms.RadioSelect, + 'checked_in_status': forms.RadioSelect, } def __init__(self, *args, **kwargs): diff --git a/src/pretix/plugins/sendmail/migrations/0005_rule_checked_in_status.py b/src/pretix/plugins/sendmail/migrations/0005_rule_checked_in_status.py new file mode 100644 index 0000000000..a3b163ad44 --- /dev/null +++ b/src/pretix/plugins/sendmail/migrations/0005_rule_checked_in_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-08-09 11:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sendmail', '0004_rule_restrict_to_status'), + ] + + operations = [ + migrations.AddField( + model_name='rule', + name='checked_in_status', + field=models.CharField(max_length=10, null=True), + ), + ] diff --git a/src/pretix/plugins/sendmail/models.py b/src/pretix/plugins/sendmail/models.py index 2c250ed108..e3e6089bb8 100644 --- a/src/pretix/plugins/sendmail/models.py +++ b/src/pretix/plugins/sendmail/models.py @@ -34,7 +34,8 @@ from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import ( - Event, InvoiceAddress, Item, Order, OrderPosition, SubEvent, fields, + Checkin, Event, InvoiceAddress, Item, Order, OrderPosition, SubEvent, + fields, ) from pretix.base.models.base import LoggingMixin from pretix.base.services.mail import SendMailException @@ -112,19 +113,30 @@ class ScheduledMail(models.Model): e = self.event orders = e.orders.all() - limit_products = self.rule.limit_products.values_list('pk', flat=True) if not self.rule.all_products else None + + filter_orders_by_op = False + op_qs = OrderPosition.objects.filter( + order__event=self.event, + canceled=False, + ) if self.subevent: - orders = orders.filter( - Exists(OrderPosition.objects.filter(order=OuterRef('pk'), subevent=self.subevent)) - ) + filter_orders_by_op = True + op_qs = op_qs.filter(subevent=self.subevent) elif e.has_subevents: return # This rule should not even exist if not self.rule.all_products: - orders = orders.filter( - Exists(OrderPosition.objects.filter(order=OuterRef('pk'), item_id__in=limit_products)) - ) + filter_orders_by_op = True + limit_products = self.rule.limit_products.values_list('pk', flat=True) + op_qs = op_qs.filter(item_id__in=limit_products) + + if self.rule.checked_in_status == "no_checkin": + filter_orders_by_op = True + op_qs = op_qs.filter(~Exists(Checkin.objects.filter(position_id=OuterRef('pk')))) + elif self.rule.checked_in_status == "checked_in": + filter_orders_by_op = True + op_qs = op_qs.filter(Exists(Checkin.objects.filter(position_id=OuterRef('pk')))) status_q = Q(status__in=self.rule.restrict_to_status) if 'n__pending_approval' in self.rule.restrict_to_status: @@ -142,6 +154,8 @@ class ScheduledMail(models.Model): pk__gt=self.last_successful_order_id ) + if filter_orders_by_op: + orders = orders.filter(pk__in=op_qs.values_list('order_id', flat=True)) orders = orders.filter( status_q, ).order_by('pk').select_related('invoice_address').prefetch_related('positions') @@ -205,6 +219,12 @@ class Rule(models.Model, LoggingMixin): (BOTH, _('Both (all order contact addresses and all attendee email addresses)')) ] + CHECK_IN_STATUS_CHOICES = [ + (None, _("Everyone")), + ("checked_in", _("Anyone who is or was checked in")), + ("no_checkin", _("Anyone who never checked in before")) + ] + id = models.BigAutoField(primary_key=True) event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='sendmail_rules') @@ -219,6 +239,15 @@ class Rule(models.Model, LoggingMixin): default=['p', 'n__valid_if_pending'], ) + checked_in_status = models.CharField( + verbose_name=_("Restrict to check-in status"), + default=None, + choices=CHECK_IN_STATUS_CHOICES, + max_length=10, + null=True, + blank=True, + ) + attach_ical = models.BooleanField( default=False, verbose_name=_("Attach calendar files"), diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html index b9c13b042e..fa9dc3ef3e 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html @@ -28,6 +28,8 @@ {% trans "Recipients" %} {% bootstrap_field form.send_to layout='control' %} {% bootstrap_field form.restrict_to_status layout='control' %} + {% bootstrap_field form.checked_in_status layout='control' %} +
{% bootstrap_field form.all_products layout='control' %} {% bootstrap_field form.limit_products layout='horizontal' %} diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html index 3a4f779c78..289c2b5fe1 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html @@ -42,6 +42,8 @@ {% trans "Recipients" %} {% bootstrap_field form.send_to layout='control' %} {% bootstrap_field form.restrict_to_status layout='control' %} + {% bootstrap_field form.checked_in_status layout='control' %} +
{% bootstrap_field form.all_products layout='control' %} {% bootstrap_field form.limit_products layout='horizontal' %} diff --git a/src/tests/api/test_sendmail.py b/src/tests/api/test_sendmail.py index fdcf97f6ec..4e876ef1ef 100644 --- a/src/tests/api/test_sendmail.py +++ b/src/tests/api/test_sendmail.py @@ -39,7 +39,8 @@ TEST_RULE_RES = { 'template': {'en': 'foo'}, 'all_products': True, 'limit_products': [], - "restrict_to_status": ['p', 'n__valid_if_pending'], + 'restrict_to_status': ['p', 'n__valid_if_pending'], + 'checked_in_status': None, 'send_date': '2021-07-08T00:00:00Z', 'send_offset_days': None, 'send_offset_time': None, @@ -160,7 +161,8 @@ def test_sendmail_rule_create_full(token_client, organizer, event, item): 'template': {'en': 'foobar'}, 'all_products': False, 'limit_products': [event.items.first().pk], - "restrict_to_status": ['p', 'n__not_pending_approval_and_not_valid_if_pending', 'n__valid_if_pending'], + 'restrict_to_status': ['p', 'n__not_pending_approval_and_not_valid_if_pending', 'n__valid_if_pending'], + 'checked_in_status': None, 'send_offset_days': 3, 'send_offset_time': '09:30', 'date_is_absolute': False, @@ -174,6 +176,7 @@ def test_sendmail_rule_create_full(token_client, organizer, event, item): assert r.all_products is False assert [i.pk for i in r.limit_products.all()] == [event.items.first().pk] assert r.restrict_to_status == ['p', 'n__not_pending_approval_and_not_valid_if_pending', 'n__valid_if_pending'] + assert r.checked_in_status is None assert r.send_offset_days == 3 assert r.send_offset_time == datetime.time(9, 30) assert r.date_is_absolute is False @@ -348,6 +351,49 @@ def test_sendmail_rule_restrict_recipients(token_client, organizer, event, rule) ) +@scopes_disabled() +@pytest.mark.django_db +def test_sendmail_rule_checkin(token_client, organizer, event, rule): + valid_states = [None, 'checked_in', 'no_checkin', ] + invalid_states = ['', 'foo'] + + for s in valid_states: + result = create_rule( + token_client, organizer, event, + data={ + 'subject': {'en': 'meow'}, + 'template': {'en': 'creative text here'}, + 'send_date': '2018-03-17T13:31Z', + 'checked_in_status': s, + }, + expected_failure=False + ) + assert result.checked_in_status == s + + for s in invalid_states: + create_rule( + token_client, organizer, event, + data={ + 'subject': {'en': 'meow'}, + 'template': {'en': 'creative text here'}, + 'send_date': '2018-03-17T13:31Z', + 'checked_in_status': s, + }, + expected_failure=True + ) + + result = create_rule( + token_client, organizer, event, + data={ + 'subject': {'en': 'meow'}, + 'template': {'en': 'creative text here'}, + 'send_date': '2018-03-17T13:31Z', + }, + expected_failure=False + ) + assert result.checked_in_status is None + + @scopes_disabled() @pytest.mark.django_db def test_sendmail_rule_change(token_client, organizer, event, rule): diff --git a/src/tests/plugins/sendmail/test_rules.py b/src/tests/plugins/sendmail/test_rules.py index 8ff6c99870..cedc7debf5 100644 --- a/src/tests/plugins/sendmail/test_rules.py +++ b/src/tests/plugins/sendmail/test_rules.py @@ -29,6 +29,7 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pretix.base.models import InvoiceAddress, Order +from pretix.base.services.checkin import perform_checkin from pretix.plugins.sendmail.models import Rule, ScheduledMail from pretix.plugins.sendmail.signals import sendmail_run_rules @@ -276,6 +277,100 @@ def test_sendmail_rule_send_correct_products(event, order, item, item2): assert djmail.outbox[0].to[0] == p1.attendee_email +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_not_checked_in_all_get_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="all", + subject='meow', template='meow meow meow') + + djmail.outbox = [] + sendmail_run_rules(None) + assert len(djmail.outbox) == 1, "email not sent" + + +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_checked_in_all_get_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test') + clist = event.checkin_lists.create(name="Default", all_products=True) + perform_checkin(p1, clist, {}) + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="all", + subject='meow', template='meow meow meow') + + djmail.outbox = [] + sendmail_run_rules(None) + assert len(djmail.outbox) == 1, "email not sent" + + +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_not_checked_in_no_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="checked_in", + subject='meow', template='meow meow meow') + + # receives no mail when not checked in + djmail.outbox = [] + sendmail_run_rules(None) + assert len(djmail.outbox) == 0, "email sent unexpectedly" + + +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_not_checked_in_get_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test') + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin", + subject='meow', template='meow meow meow') + + # receives mail when not checked in + djmail.outbox = [] + sendmail_run_rules(None) + assert len(djmail.outbox) == 1, "email not sent" + + +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_checked_in_no_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test') + clist = event.checkin_lists.create(name="Default", all_products=True) + + # receives no mail when checked in + djmail.outbox = [] + perform_checkin(p1, clist, {}) + assert clist.checkin_count == 1 + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin", + subject='meow', template='meow meow meow') + sendmail_run_rules(None) + assert len(djmail.outbox) == 0, "email sent unexpectedly" + + +@pytest.mark.django_db +@scopes_disabled() +def test_sendmail_rule_checked_in_get_mail(event, order, item): + order.status = Order.STATUS_PAID + order.save() + p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test') + clist = event.checkin_lists.create(name="Default", all_products=True) + + # receives mail when checked in + djmail.outbox = [] + perform_checkin(p1, clist, {}) + assert clist.checkin_count == 1 + event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="checked_in", + subject='meow', template='meow meow meow') + sendmail_run_rules(None) + assert len(djmail.outbox) == 1, "email not sent" + + @pytest.mark.django_db @scopes_disabled() def run_restriction_test(event, order, restrictions_pass=[], restrictions_fail=[]):