From f33da63b5c4cd86a9cb53665876ce3121b74dfb3 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 30 Apr 2021 00:39:38 +0200 Subject: [PATCH 5/5] lingo: poll running transactions status in cells (#49149) Polling is done in RemoteItem.update_paid(), BasketItem.get_items_to_be_paid() and LingoRecentTransactionsCell.get_transactions_queryset(), so that an user refreshing the payment pages see the latest status of the items transactions. --- combo/apps/lingo/models.py | 58 +++++++++--- .../lingo/templates/lingo/combo/item.html | 5 +- .../lingo/templates/lingo/combo/items.html | 2 +- combo/apps/lingo/views.py | 23 +++++ combo/public/views.py | 2 + tests/test_lingo_payment.py | 58 ++++++++++++ tests/test_lingo_remote_regie.py | 91 +++++++++++++++++++ 7 files changed, 222 insertions(+), 17 deletions(-) diff --git a/combo/apps/lingo/models.py b/combo/apps/lingo/models.py index 7504a3ac..939dad4f 100644 --- a/combo/apps/lingo/models.py +++ b/combo/apps/lingo/models.py @@ -598,9 +598,13 @@ class BasketItem(models.Model): @classmethod def get_items_to_be_paid(cls, user): - return cls.objects.filter( + qs = cls.objects.filter( user=user, payment_date__isnull=True, waiting_date__isnull=True, cancellation_date__isnull=True ) + for transaction in Transaction.objects.filter(items__in=qs): + if transaction.can_poll_backend(): + transaction.poll_backend() + return qs def notify(self, status): if not self.source_url: @@ -682,6 +686,7 @@ class RemoteItem(object): self.reference_id = reference_id if payment_date: self.payment_date = parser.parse(payment_date) + self.waiting_date = None @property def no_online_payment_reason_details(self): @@ -699,28 +704,44 @@ class RemoteItem(object): return aes_hex_encrypt(settings.SECRET_KEY, force_bytes(str(self.id))) @classmethod - def update_paid(cls, regie, remote_items): - remote_item_ids = [remote_item.id for remote_item in remote_items if not remote_item.paid] + def transactions_for_remote_items(cls, queryset, remote_items): + remote_item_ids = set(remote_item.id for remote_item in remote_items if not remote_item.paid) if not remote_item_ids: - return + return Transaction.objects.none() - paid_items = {} # filter transactions by regie, status and contained remote_item id - transaction_qs = Transaction.objects.filter( - regie=regie, status__in=[eopayment.PAID, eopayment.ACCEPTED] - ) query = reduce( models.Q.__or__, (models.Q(remote_items__contains=remote_item_id) for remote_item_id in remote_item_ids), ) # accumulate in paid_items each remote_item earliest payment_date - for transaction in transaction_qs.filter(query): + for transaction in queryset.filter(query): + for remote_item_id in transaction.remote_items.split(','): + if remote_item_id in remote_item_ids: + yield transaction + break + + @classmethod + def update_paid(cls, regie, remote_items): + paid_items = {} + waiting_items = {} + transaction_qs = Transaction.objects.filter(regie=regie) + + can_poll_backend = regie.can_poll_backend() + + # accumulate in paid_items each remote_item earliest payment_date + for transaction in cls.transactions_for_remote_items(transaction_qs, remote_items): + if transaction.is_running() and can_poll_backend: + transaction.poll_backend() for remote_item in transaction.remote_items.split(','): - if remote_item not in paid_items: - paid_items[remote_item] = transaction.end_date - else: - paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item]) + if transaction.end_date and transaction.is_paid(): + if remote_item not in paid_items: + paid_items[remote_item] = transaction.end_date + else: + paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item]) + elif transaction.status == eopayment.WAITING and can_poll_backend: + waiting_items[remote_item] = transaction.start_date # update remote_item.paid using paid_items for remote_item in remote_items: @@ -729,6 +750,8 @@ class RemoteItem(object): if remote_item.id in paid_items: remote_item.paid = True remote_item.payment_date = paid_items[remote_item.id] + elif remote_item.id in waiting_items: + remote_item.waiting_date = waiting_items[remote_item.id] def status_label(status): @@ -758,6 +781,7 @@ class Transaction(models.Model): amount = models.DecimalField(default=0, max_digits=7, decimal_places=2) RUNNING_STATUSES = [0, eopayment.WAITING, eopayment.RECEIVED] + PAID_STATUSES = [eopayment.PAID, eopayment.ACCEPTED] def is_remote(self): return self.remote_items != '' @@ -768,7 +792,7 @@ class Transaction(models.Model): return _('Anonymous User') def is_paid(self): - return self.status in (eopayment.PAID, eopayment.ACCEPTED) + return self.status in self.PAID_STATUSES def is_running(self): return self.status in self.RUNNING_STATUSES @@ -1012,9 +1036,13 @@ class LingoRecentTransactionsCell(CellBase): # list transactions : # * paid by the user # * or linked to a BasketItem of the user - return Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter( + qs = Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter( start_date__gte=timezone.now() - datetime.timedelta(days=7) ) + for transaction in qs: + if transaction.can_poll_backend() and transaction.is_running(): + transaction.poll_backend() + return qs def is_relevant(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): diff --git a/combo/apps/lingo/templates/lingo/combo/item.html b/combo/apps/lingo/templates/lingo/combo/item.html index 6ff7d877..1c43988f 100644 --- a/combo/apps/lingo/templates/lingo/combo/item.html +++ b/combo/apps/lingo/templates/lingo/combo/item.html @@ -48,7 +48,10 @@ {% if item.no_online_payment_reason_details %}
{{ item.no_online_payment_reason_details }}
{% endif %} - {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount %} + {% if item.waiting_date and not item.paid %} + + {% endif %} + {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount and not item.waiting_date %} {% csrf_token %} {% if not user.is_authenticated %}
diff --git a/combo/apps/lingo/templates/lingo/combo/items.html b/combo/apps/lingo/templates/lingo/combo/items.html index d7d87104..2f3f8997 100644 --- a/combo/apps/lingo/templates/lingo/combo/items.html +++ b/combo/apps/lingo/templates/lingo/combo/items.html @@ -51,7 +51,7 @@ {% if item.regie.is_remote %} {% trans "View" %} - {% if item.online_payment and item.amount >= item.regie.payment_min_amount %}{% trans "and pay" %}{% endif %} + {% if item.online_payment and item.amount >= item.regie.payment_min_amount and not item.waiting_date %}{% trans "and pay" %}{% endif %} {% if item.has_pdf %}
1 or len(remote_items) > 1): messages.error(request, _('This regie allows to pay only one item.')) return HttpResponseRedirect(next_url) @@ -491,6 +500,20 @@ class PayMixin(object): raise NotImplementedError() + def poll_for_newly_paid_or_still_running_transactions(self, regie, items, remote_items): + '''Verify if any open transaction is not already paid.''' + qs = Transaction.objects.filter(regie=regie, status__in=Transaction.RUNNING_STATUSES) + if items: + transactions = qs.filter(items__in=items) + else: + transactions = RemoteItem.transactions_for_remote_items(qs, remote_items) + + newly_paid_or_still_running = False + for transaction in transactions: + transaction.poll_backend() + newly_paid_or_still_running |= transaction.is_paid() or transaction.is_running() + return newly_paid_or_still_running + class PayView(PayMixin, View): def post(self, request, *args, **kwargs): diff --git a/combo/public/views.py b/combo/public/views.py index 1d568838..21e40c1d 100644 --- a/combo/public/views.py +++ b/combo/public/views.py @@ -586,6 +586,8 @@ def publish_page(request, page, status=200, template_name=None): } ctx.update(getattr(request, 'extra_context_data', {})) modify_global_context(request, ctx) + if getattr(settings, 'COMBO_TEST_SYNCHRONOUS', False): + ctx['synchronous'] = True for cell in cells: if cell.modify_global_context: diff --git a/tests/test_lingo_payment.py b/tests/test_lingo_payment.py index e73f7704..476c22c6 100644 --- a/tests/test_lingo_payment.py +++ b/tests/test_lingo_payment.py @@ -26,6 +26,7 @@ from combo.apps.lingo.models import ( EXPIRED, BasketItem, LingoBasketCell, + LingoRecentTransactionsCell, PaymentBackend, Regie, Transaction, @@ -2090,3 +2091,60 @@ class TestPayfip: ) as transaction_poll_backend: payment_backend.poll_backend() transaction_poll_backend.assert_called_once_with(transaction) + + @mock.patch('eopayment.payfip_ws.Payment.payment_status') + @mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/')) + def test_payfip_poll_for_newly_paid_transactions( + self, payment_request, payment_status, app, basket_page, mono_regie, user + ): + item = BasketItem.objects.create( + user=user, + regie=mono_regie, + amount=42, + subject='foo item', + request_data={'refdet': 'F20201030', 'exer': '2020'}, + ) + cell = LingoRecentTransactionsCell(page=basket_page, placeholder='content', order=1) + cell.save() + + login(app) + + # Try to pay + pay_resp = app.get('/test_basket_cell/') + assert 'foo item' in pay_resp + assert 'Running' not in pay_resp + resp = pay_resp.click('Pay') + # we are redirect to payfip + assert resp.location == 'https://payfip/' + + transaction = Transaction.objects.filter(items__in=[item]).get() + + # Simulate still running status on polling + payment_status.return_value = eopayment.common.PaymentResponse( + signed=True, + result=eopayment.WAITING, + order_id=transaction.order_id, + ) + + # Try to pay again + resp = app.get('/test_basket_cell/') + assert 'foo item' not in resp + assert 'Pay' not in resp + assert 'Running' in resp + resp = pay_resp.click('Pay').follow() + assert 'Some items are already paid or' in resp + assert 'foo item' not in resp + assert 'Running' in resp + + # Simulate still paid status on polling + payment_status.return_value = eopayment.common.PaymentResponse( + signed=True, + result=eopayment.PAID, + order_id=transaction.order_id, + ) + + # Try to pay again + resp = app.get('/test_basket_cell/') + assert 'foo item: 42.00' in resp + assert 'Pay' not in resp + assert 'Running' not in resp diff --git a/tests/test_lingo_remote_regie.py b/tests/test_lingo_remote_regie.py index 46a3ff63..fe70a315 100644 --- a/tests/test_lingo_remote_regie.py +++ b/tests/test_lingo_remote_regie.py @@ -5,6 +5,7 @@ import json from decimal import Decimal import eopayment +import httmock import mock import pytest from django.apps import apps @@ -32,6 +33,8 @@ from combo.apps.lingo.models import ( from combo.data.models import Page from combo.utils import aes_hex_encrypt, check_query +from .test_manager import login + pytestmark = pytest.mark.django_db @@ -742,3 +745,91 @@ def test_send_new_remote_invoices_by_email(mock_get, user_saml, admin, app, remo assert 'http://localhost' in html_message assert mailoutbox[0].attachments[0][0] == '01.pdf' assert mailoutbox[0].attachments[0][2] == 'application/pdf' + + +@pytest.fixture +def remote_invoices_httmock(): + invoices = [] + invoice = {} + + netloc = 'remote.regie.example.com' + + @httmock.urlmatch(netloc=netloc, path='^/invoice/') + def invoice_mock(url, request): + return json.dumps({'err': 0, 'data': invoice}) + + @httmock.urlmatch(netloc=netloc, path='^/invoices/') + def invoices_mock(url, request): + return json.dumps({'err': 0, 'data': invoices}) + + context_manager = httmock.HTTMock(invoices_mock, invoice_mock) + context_manager.url = 'https://%s/' % netloc + context_manager.invoices = invoices + context_manager.invoice = invoice + with context_manager: + yield context_manager + + +@mock.patch('eopayment.payfip_ws.Payment.payment_status') +@mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/')) +def test_remote_regie_poll_backend( + payment_request, payment_status, app, remote_regie, settings, remote_invoices_httmock +): + settings.COMBO_TEST_SYNCHRONOUS = True + + remote_invoices_httmock.invoices.extend(INVOICES) + remote_invoices_httmock.invoice.update(INVOICES[0]) + remote_regie.webservice_url = remote_invoices_httmock.url + remote_regie.save() + # use payfip + remote_regie.payment_backend.service = 'payfip_ws' + remote_regie.payment_backend.save() + + User.objects.create_user('admin', password='admin', email='foo@example.com') + page = Page.objects.create(title='xxx', slug='test_basket_cell', template_name='standard') + ActiveItems.objects.create(regie='remote', page=page, placeholder='content', order=0) + + login(app) + + assert Transaction.objects.count() == 0 + + resp = app.get('/test_basket_cell/?sync') + assert 'F-2016-One' in resp + + resp = resp.click('pay', index=0) + pay_resp = resp + resp = resp.form.submit('Pay') + + transaction = Transaction.objects.get() + assert transaction.status == 0 + + payment_status.return_value = eopayment.common.PaymentResponse( + signed=True, + result=eopayment.WAITING, + order_id=transaction.order_id, + ) + + assert payment_status.call_count == 0 + resp = app.get('/test_basket_cell/?sync') + assert 'F-2016-One' in resp + assert payment_status.call_count == 1 + transaction.refresh_from_db() + assert transaction.status == eopayment.WAITING + + resp = resp.click('pay', index=0) + assert 'Waiting for payment' in resp + assert 'button' not in resp + + resp = pay_resp.form.submit('Pay').follow() + assert 'Some items are already paid' in resp + + payment_status.return_value = eopayment.common.PaymentResponse( + signed=True, + result=eopayment.PAID, + order_id=transaction.order_id, + ) + + resp = app.get('/test_basket_cell/?sync') + assert 'F-2016-One' not in resp + transaction.refresh_from_db() + assert transaction.status == eopayment.PAID -- 2.31.1