mirror of
https://github.com/pretix/pretix.git
synced 2026-08-19 12:16:26 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a3993cef | ||
|
|
4969c12c0a | ||
|
|
0b319d201c | ||
|
|
eceeb9caa7 | ||
|
|
3c17d05d05 | ||
|
|
1c17c35b16 | ||
|
|
f9ea9de078 |
@@ -561,6 +561,22 @@ def base_placeholders(sender, **kwargs):
|
||||
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||
lambda event: str(event.location or ''),
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_begin_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_from.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_from
|
||||
else '',
|
||||
lambda event: date_format(event.date_from.astimezone(event.timezone), 'TIME_FORMAT') if event.date_from else '',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_end_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
date_format(event_or_subevent.date_to.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
|
||||
if event_or_subevent.date_to
|
||||
else '',
|
||||
lambda event: date_format(event.date_to.astimezone(event.timezone), 'TIME_FORMAT') if event.date_to else '',
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent:
|
||||
|
||||
@@ -2008,7 +2008,10 @@ class OrderChange(OrderView):
|
||||
'issued_gift_cards',
|
||||
'addons',
|
||||
).annotate(
|
||||
_seat_allowed=Exists(SeatCategoryMapping.objects.filter(subevent=OuterRef("subevent"), product=OuterRef("item")))
|
||||
_seat_allowed=Exists(SeatCategoryMapping.objects.filter(
|
||||
subevent=OuterRef("subevent") if self.request.event.has_subevents else None,
|
||||
product=OuterRef("item"))
|
||||
)
|
||||
))
|
||||
for p in positions:
|
||||
p.form = OrderPositionChangeForm(
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib import messages
|
||||
@@ -361,7 +362,13 @@ def webhook(request, *args, **kwargs):
|
||||
if event_json['resource_type'] == 'checkout-order':
|
||||
payloadid = event_json['resource']['id']
|
||||
elif event_json['resource_type'] == 'refund' or event_json['resource_type'] == 'capture':
|
||||
payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1]
|
||||
payloadid = get_order_id(event_json.get('resource', {}).get('links', []))
|
||||
if payloadid is None:
|
||||
# if we get a PAYMENT.CAPTURE.DECLINED webhook because a capture wasn't created due to
|
||||
# violated validations, then it is labeled as a `capture` ressource_type but is in fact
|
||||
# an `order` ressource_type as there is no `capture`. So we have to fall back
|
||||
# See test_webhook_capture_declined for a redacted payload we've received
|
||||
payloadid = event_json['resource']['id']
|
||||
else:
|
||||
return HttpResponse("Not interested in this resource type", status=200)
|
||||
|
||||
@@ -426,6 +433,22 @@ def webhook(request, *args, **kwargs):
|
||||
payment.info = json.dumps(sale.dict())
|
||||
payment.save()
|
||||
|
||||
# the captures[] of the sales object only is populated if the capture request isn't rejected.
|
||||
# the capture request might be rejected if certain validations aren't met OR if the payment is
|
||||
# DECLINED, nevertheless we will get a webhook informing us about "PAYMENT.CAPTURE.DECLINED".
|
||||
# With no trace of it in `sale`.
|
||||
# So now we have to leave our current pattern of making only decisions based upon the
|
||||
# complete payment object (and checking whenever we receive a webhook), and instead need to fail
|
||||
# payment directly.
|
||||
# Otherwise we are caught in a loop:
|
||||
# 1. We get a webhook and get `sale`
|
||||
# 2. We see no proof of a capture attempt in `sale`
|
||||
# 3. We call execute_payment and trigger a new "PAYMENT.CAPTURE.DECLINED" webhook, GOTO 1
|
||||
if event_json['event_type'] == "PAYMENT.CAPTURE.DECLINED":
|
||||
payment.fail(log_data={'status': event_json['event_type']})
|
||||
logger.exception('PayPal Webhook PAYMENT.CAPTURE.DECLINED: {}'.format(event_json))
|
||||
return HttpResponse(status=200)
|
||||
|
||||
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
|
||||
if event_json['resource_type'] == 'refund':
|
||||
try:
|
||||
@@ -473,6 +496,7 @@ def webhook(request, *args, **kwargs):
|
||||
if sale['status'] == 'COMPLETED':
|
||||
all_captures_completed = True
|
||||
any_pending_review = False
|
||||
any_failed = None
|
||||
for purchaseunit in sale['purchase_units']:
|
||||
for capture in purchaseunit['payments']['captures']:
|
||||
try:
|
||||
@@ -481,14 +505,19 @@ def webhook(request, *args, **kwargs):
|
||||
except ReferencedPayPalObject.MultipleObjectsReturned:
|
||||
pass
|
||||
|
||||
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||
if capture['status'] in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
|
||||
pass
|
||||
elif capture['status'] in ("DECLINED", "FAILED"):
|
||||
all_captures_completed = False
|
||||
if capture['status_details']['reason'] == "PENDING_REVIEW":
|
||||
any_failed = True
|
||||
elif capture['status'] in ('PENDING'):
|
||||
all_captures_completed = False
|
||||
if capture.get('status_details', {}).get('reason', "") == "PENDING_REVIEW":
|
||||
any_pending_review = True
|
||||
else:
|
||||
raise ValueError("Unknown paypal capture state: {}".format(capture['status']))
|
||||
if all_captures_completed:
|
||||
try:
|
||||
payment.info = json.dumps(sale.dict())
|
||||
payment.save(update_fields=['info'])
|
||||
payment.confirm()
|
||||
prov.log_payment_duration(payment)
|
||||
except Quota.QuotaExceededException:
|
||||
@@ -496,6 +525,8 @@ def webhook(request, *args, **kwargs):
|
||||
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
|
||||
payment.state = OrderPayment.PAYMENT_STATE_PENDING
|
||||
payment.save(update_fields=['state'])
|
||||
if any_failed:
|
||||
payment.fail()
|
||||
elif sale['status'] == 'APPROVED':
|
||||
try:
|
||||
request.session['payment_paypal_oid'] = payment.info_data['id']
|
||||
@@ -529,9 +560,17 @@ def isu_disconnect(request, **kwargs):
|
||||
}))
|
||||
|
||||
|
||||
def get_link(links, rel):
|
||||
ORDER_ID_RE = re.compile(r"/checkout/orders/([^/?]+)")
|
||||
|
||||
|
||||
def get_order_id(links):
|
||||
for link in links:
|
||||
if link['rel'] == rel:
|
||||
return link
|
||||
if link.get('rel', "") == "up" and link.get('href', None) is not None:
|
||||
return link['href'].split('/')[-1]
|
||||
|
||||
for link in links:
|
||||
match = ORDER_ID_RE.search(link.get("href", ""))
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
@@ -384,8 +384,8 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
|
||||
]
|
||||
)
|
||||
|
||||
self._set_field_placeholders('subject', ['event', 'order', 'event_or_subevent'])
|
||||
self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent'], rich=True)
|
||||
self._set_field_placeholders('subject', ['event', 'order', 'event_or_subevent', 'position_or_address'])
|
||||
self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent', 'position_or_address'], rich=True)
|
||||
|
||||
choices = [
|
||||
(Order.STATUS_PAID, _('Paid (or canceled with paid fee)')),
|
||||
|
||||
@@ -657,7 +657,7 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView):
|
||||
|
||||
for lang in self.request.event.settings.locales:
|
||||
with language(lang, self.request.event.settings.region):
|
||||
placeholders = get_sample_context(self.request.event, ['event', 'order', 'position_or_address'])
|
||||
placeholders = get_sample_context(self.request.event, ['event', 'order', 'event_or_subevent', 'position_or_address'])
|
||||
subject = bleach.clean(self.object.subject.localize(lang), tags=set())
|
||||
preview_subject = prefix_subject(self.request.event, format_map(subject, placeholders), highlight=True)
|
||||
template = self.object.template.localize(lang)
|
||||
|
||||
@@ -19,19 +19,9 @@
|
||||
# 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 hashlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.base import ContentFile, File
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.presale.views.widget import (
|
||||
generate_widget_js, version_max, version_min,
|
||||
)
|
||||
from pretix.presale.views.widget import regenerate_all_widget_js
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -41,26 +31,5 @@ class Command(BaseCommand):
|
||||
parser.add_argument('--organizer', action='store', type=str)
|
||||
parser.add_argument('--event', action='store', type=str)
|
||||
|
||||
@scopes_disabled()
|
||||
def handle(self, *args, **options):
|
||||
gs = GlobalSettingsObject()
|
||||
for lc, ll in settings.LANGUAGES:
|
||||
for version in range(version_min, version_max + 1):
|
||||
data = generate_widget_js(version, lc).encode()
|
||||
checksum = hashlib.sha1(data).hexdigest()
|
||||
settings_file_key = 'widget_file_v{}_{}'.format(version, lc)
|
||||
settings_checksum_key = 'widget_checksum_v{}_{}'.format(version, lc)
|
||||
fname = gs.settings.get(settings_file_key)
|
||||
if not fname or gs.settings.get(settings_checksum_key, '') != checksum:
|
||||
newname = default_storage.save(
|
||||
'pub/widget/widget.v{}.{}.{}.js'.format(version, lc, checksum),
|
||||
ContentFile(data)
|
||||
)
|
||||
gs.settings.set(settings_file_key, 'file://' + newname)
|
||||
gs.settings.set(settings_checksum_key, checksum)
|
||||
cache.delete('widget_js_data_v{}_{}'.format(version, lc))
|
||||
if fname:
|
||||
if isinstance(fname, File):
|
||||
default_storage.delete(fname.name)
|
||||
else:
|
||||
default_storage.delete(fname)
|
||||
regenerate_all_widget_js()
|
||||
|
||||
@@ -229,6 +229,64 @@ def generate_widget_js(version, lang, use_vite=False):
|
||||
return f"/* v{version} */\n" + code
|
||||
|
||||
|
||||
def get_widget_js(version, lang, use_vite, force_regenerate=False):
|
||||
if settings.DEBUG:
|
||||
return generate_widget_js(version, lang, use_vite=use_vite).encode()
|
||||
|
||||
variant = 'vite' if use_vite else 'legacy'
|
||||
cache_prefix = 'widget_js_data_v{}_{}_{}'.format(version, lang, variant)
|
||||
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
|
||||
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
|
||||
gs = GlobalSettingsObject()
|
||||
|
||||
if not force_regenerate:
|
||||
cached_js = cache.get(cache_prefix)
|
||||
if cached_js:
|
||||
return cached_js
|
||||
|
||||
fname = gs.settings.get(settings_key)
|
||||
if fname:
|
||||
if isinstance(fname, File):
|
||||
fname = fname.name
|
||||
try:
|
||||
data = default_storage.open(fname).read()
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
return data
|
||||
except:
|
||||
fname = None
|
||||
logger.exception('Failed to open widget.js')
|
||||
else:
|
||||
fname = gs.settings.get(settings_key)
|
||||
|
||||
data = generate_widget_js(version, lang, use_vite=use_vite).encode()
|
||||
checksum = hashlib.sha1(data).hexdigest()
|
||||
should_save = (
|
||||
not fname
|
||||
or gs.settings.get(checksum_key, '') != checksum
|
||||
)
|
||||
if should_save:
|
||||
newname = default_storage.save(
|
||||
'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
|
||||
ContentFile(data)
|
||||
)
|
||||
gs.settings.set(settings_key, 'file://' + newname)
|
||||
gs.settings.set(checksum_key, checksum)
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
if fname:
|
||||
if isinstance(fname, File):
|
||||
default_storage.delete(fname.name)
|
||||
else:
|
||||
default_storage.delete(fname)
|
||||
return data
|
||||
|
||||
|
||||
def regenerate_all_widget_js():
|
||||
for lc, ll in settings.LANGUAGES:
|
||||
for version in range(version_min, version_max + 1):
|
||||
for use_vite in [True, False]:
|
||||
get_widget_js(version, lc, use_vite, force_regenerate=True)
|
||||
|
||||
|
||||
@gzip_page
|
||||
@condition(etag_func=widget_js_etag)
|
||||
def widget_js(request, version, lang, **kwargs):
|
||||
@@ -239,43 +297,9 @@ def widget_js(request, version, lang, **kwargs):
|
||||
version = version_min
|
||||
|
||||
use_vite = _use_vite(request)
|
||||
variant = 'vite' if use_vite else 'legacy'
|
||||
cache_prefix = 'widget_js_data_v{}_{}_{}'.format(version, lang, variant)
|
||||
data = get_widget_js(version, lang, use_vite)
|
||||
|
||||
cached_js = cache.get(cache_prefix)
|
||||
if cached_js and not settings.DEBUG:
|
||||
resp = HttpResponse(cached_js, content_type='text/javascript')
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
|
||||
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
fname = gs.settings.get(settings_key)
|
||||
resp = None
|
||||
if fname and not settings.DEBUG:
|
||||
if isinstance(fname, File):
|
||||
fname = fname.name
|
||||
try:
|
||||
data = default_storage.open(fname).read()
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
except:
|
||||
logger.exception('Failed to open widget.js')
|
||||
|
||||
if not resp:
|
||||
data = generate_widget_js(version, lang, use_vite=use_vite).encode()
|
||||
checksum = hashlib.sha1(data).hexdigest()
|
||||
if not settings.DEBUG:
|
||||
newname = default_storage.save(
|
||||
'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
|
||||
ContentFile(data)
|
||||
)
|
||||
gs.settings.set(settings_key, 'file://' + newname)
|
||||
gs.settings.set(checksum_key, checksum)
|
||||
cache.set(cache_prefix, data, 3600 * 4)
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
resp = HttpResponse(data, content_type='text/javascript')
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ Vue.component('pricebox', {
|
||||
+ ' :min="display_price_nonlocalized" :value="suggested_price_nonlocalized" :name="field_name"'
|
||||
+ ' step="any" v-bind:aria-labelledby="aria_labelledby" v-bind:aria-describedby="price_desc_id">'
|
||||
+ '</div>'
|
||||
+ '<small class="pretix-widget-pricebox-tax" :id="price_desc_id" v-if="price.rate != \'0.00\' && price.gross != \'0.00\'">'
|
||||
+ '<small class="pretix-widget-pricebox-tax" :id="price_desc_id" v-if="price.rate != \'0\' && price.gross != \'0.00\'">'
|
||||
+ '{{ taxline }}'
|
||||
+ '</small>'
|
||||
+ '</div>'),
|
||||
@@ -534,7 +534,7 @@ Vue.component('item', {
|
||||
+ '<div :id="item_price_id" class="pretix-widget-item-price-col">'
|
||||
+ '<pricebox :price="item.price" :free_price="item.free_price" v-if="!item.has_variations && $root.showPrices"'
|
||||
+ ' :mandatory_priced_addons="item.mandatory_priced_addons" :suggested_price="item.suggested_price"'
|
||||
+ ' :field_name="\'price_\' + item.id" :original_price="item.original_price">'
|
||||
+ ' :field_name="\'price_\' + item.id" :original_price="item.original_price" :item_id="item.id">'
|
||||
+ '</pricebox>'
|
||||
+ '<div class="pretix-widget-pricebox" v-if="item.has_variations && $root.showPrices" v-html="pricerange"></div>'
|
||||
+ '<span v-if="!$root.showPrices"> </span>'
|
||||
|
||||
@@ -86,7 +86,7 @@ const taxline = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const showTaxline = computed(() => props.price.rate !== '0.00' && props.price.gross !== '0.00')
|
||||
const showTaxline = computed(() => props.price.rate !== '0' && props.price.gross !== '0.00')
|
||||
</script>
|
||||
<template lang="pug">
|
||||
.pretix-widget-pricebox
|
||||
|
||||
@@ -837,3 +837,62 @@ def test_webhook_pending_payment(env, client, monkeypatch):
|
||||
order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_webhook_capture_declined(env, client, monkeypatch):
|
||||
order = env[1]
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
with scopes_disabled():
|
||||
order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED)
|
||||
|
||||
pp_order = Result(get_test_order_review_pending())
|
||||
mock_orders_get_request = MagicMock(return_value=pp_order)
|
||||
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request)
|
||||
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
|
||||
with scopes_disabled():
|
||||
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
|
||||
reference="806440346Y391300T")
|
||||
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
|
||||
client.post('/_paypal/webhook/', json.dumps(
|
||||
{
|
||||
"create_time": "2026-08-17T12:23:30.687Z",
|
||||
"event_type": "PAYMENT.CAPTURE.DECLINED",
|
||||
"event_version": "1.0",
|
||||
"id": "WH-XXXXXXXXXXXX-XXXXXXXXX",
|
||||
"links": [
|
||||
{
|
||||
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX",
|
||||
"method": "GET",
|
||||
"rel": "self"
|
||||
},
|
||||
{
|
||||
"href": "https://api.paypal.com/v1/notifications/webhooks-events/WH-XXXXXXXXXXXX-XXXXXXXXX/resend",
|
||||
"method": "POST",
|
||||
"rel": "resend"
|
||||
}
|
||||
],
|
||||
"resource": {
|
||||
"amount": {},
|
||||
"custom_id": "Order ABC-12345",
|
||||
"disbursement_mode": "INSTANT",
|
||||
"final_capture": True,
|
||||
"id": "806440346Y391300T",
|
||||
"payee": {},
|
||||
"seller_protection": {},
|
||||
"seller_receivable_breakdown": {},
|
||||
"status": "DECLINED",
|
||||
"supplementary_data": {}
|
||||
},
|
||||
"resource_type": "capture",
|
||||
"resource_version": "2.0",
|
||||
"summary": "A payment capture for € 30.0 EUR was declined."
|
||||
}), content_type='application_json')
|
||||
|
||||
order = env[1]
|
||||
order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_FAILED
|
||||
|
||||
@@ -35,6 +35,8 @@ def event():
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now(), live=True,
|
||||
plugins='pretix.plugins.sendmail,tests.testdummy',
|
||||
location='Foo City',
|
||||
date_admission=now().replace(hour=11, minute=30),
|
||||
)
|
||||
return event
|
||||
|
||||
@@ -81,7 +83,12 @@ def event_series(event):
|
||||
|
||||
@pytest.fixture
|
||||
def subevent1(event_series):
|
||||
se1 = event_series.subevents.create(name='Meow', date_from=now() + datetime.timedelta(days=1))
|
||||
se1 = event_series.subevents.create(
|
||||
name='Meow',
|
||||
date_from=now() + datetime.timedelta(days=1),
|
||||
location='Meow Town',
|
||||
date_admission=now().replace(hour=10, minute=0),
|
||||
)
|
||||
return se1
|
||||
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_sendmail_rule_send_order_vs_pos(send_to, amount_mails, recipients, orde
|
||||
|
||||
order.event.sendmail_rules.create(date_is_absolute=True, send_date=dt_now - datetime.timedelta(hours=1),
|
||||
send_to=send_to,
|
||||
subject='meow', template='meow meow meow')
|
||||
subject='{event}: {event_location} @ {event_admission_time}', template='meow meow meow')
|
||||
order.all_positions.create(item=item, price=0, attendee_email='meow@dummy.test')
|
||||
|
||||
sendmail_run_rules(None)
|
||||
@@ -196,6 +196,8 @@ def test_sendmail_rule_send_order_vs_pos(send_to, amount_mails, recipients, orde
|
||||
_recipients = [mail.to[0] for mail in djmail.outbox]
|
||||
assert set(recipients) == set(_recipients)
|
||||
|
||||
assert djmail.outbox[0].subject == 'Dummy: Foo City @ 11:30'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@scopes_disabled()
|
||||
@@ -243,7 +245,7 @@ def test_sendmail_rule_send_correct_subevent(order, event_series, subevent1, sub
|
||||
|
||||
event_series.sendmail_rules.create(date_is_absolute=False, offset_is_after=False, send_offset_days=2,
|
||||
send_offset_time=datetime.time(9, 30), send_to=Rule.ATTENDEES,
|
||||
subject='meow', template='meow meow meow')
|
||||
subject='{event}: {event_location} @ {event_admission_time}', template='meow meow meow')
|
||||
p1 = order.all_positions.create(item=item, price=13, attendee_email='se1@dummy.test', subevent=subevent1)
|
||||
order.all_positions.create(item=item, price=23, attendee_email='se2@dummy.test', subevent=subevent2)
|
||||
|
||||
@@ -253,6 +255,8 @@ def test_sendmail_rule_send_correct_subevent(order, event_series, subevent1, sub
|
||||
|
||||
assert djmail.outbox[0].to[0] == p1.attendee_email
|
||||
|
||||
assert djmail.outbox[0].subject == 'Meow: Meow Town @ 10:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@scopes_disabled()
|
||||
|
||||
Reference in New Issue
Block a user