Projet

Général

Profil

0005-lingo-poll-running-transactions-status-in-cells-4914.patch

Benjamin Dauvergne, 30 avril 2021 02:33

Télécharger (16,9 ko)

Voir les différences:

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(-)
combo/apps/lingo/models.py
598 598

  
599 599
    @classmethod
600 600
    def get_items_to_be_paid(cls, user):
601
        return cls.objects.filter(
601
        qs = cls.objects.filter(
602 602
            user=user, payment_date__isnull=True, waiting_date__isnull=True, cancellation_date__isnull=True
603 603
        )
604
        for transaction in Transaction.objects.filter(items__in=qs):
605
            if transaction.can_poll_backend():
606
                transaction.poll_backend()
607
        return qs
604 608

  
605 609
    def notify(self, status):
606 610
        if not self.source_url:
......
682 686
        self.reference_id = reference_id
683 687
        if payment_date:
684 688
            self.payment_date = parser.parse(payment_date)
689
        self.waiting_date = None
685 690

  
686 691
    @property
687 692
    def no_online_payment_reason_details(self):
......
699 704
        return aes_hex_encrypt(settings.SECRET_KEY, force_bytes(str(self.id)))
700 705

  
701 706
    @classmethod
702
    def update_paid(cls, regie, remote_items):
703
        remote_item_ids = [remote_item.id for remote_item in remote_items if not remote_item.paid]
707
    def transactions_for_remote_items(cls, queryset, remote_items):
708
        remote_item_ids = set(remote_item.id for remote_item in remote_items if not remote_item.paid)
704 709
        if not remote_item_ids:
705
            return
710
            return Transaction.objects.none()
706 711

  
707
        paid_items = {}
708 712
        # filter transactions by regie, status and contained remote_item id
709
        transaction_qs = Transaction.objects.filter(
710
            regie=regie, status__in=[eopayment.PAID, eopayment.ACCEPTED]
711
        )
712 713
        query = reduce(
713 714
            models.Q.__or__,
714 715
            (models.Q(remote_items__contains=remote_item_id) for remote_item_id in remote_item_ids),
715 716
        )
716 717

  
717 718
        # accumulate in paid_items each remote_item earliest payment_date
718
        for transaction in transaction_qs.filter(query):
719
        for transaction in queryset.filter(query):
720
            for remote_item_id in transaction.remote_items.split(','):
721
                if remote_item_id in remote_item_ids:
722
                    yield transaction
723
                    break
724

  
725
    @classmethod
726
    def update_paid(cls, regie, remote_items):
727
        paid_items = {}
728
        waiting_items = {}
729
        transaction_qs = Transaction.objects.filter(regie=regie)
730

  
731
        can_poll_backend = regie.can_poll_backend()
732

  
733
        # accumulate in paid_items each remote_item earliest payment_date
734
        for transaction in cls.transactions_for_remote_items(transaction_qs, remote_items):
735
            if transaction.is_running() and can_poll_backend:
736
                transaction.poll_backend()
719 737
            for remote_item in transaction.remote_items.split(','):
720
                if remote_item not in paid_items:
721
                    paid_items[remote_item] = transaction.end_date
722
                else:
723
                    paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
738
                if transaction.end_date and transaction.is_paid():
739
                    if remote_item not in paid_items:
740
                        paid_items[remote_item] = transaction.end_date
741
                    else:
742
                        paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
743
                elif transaction.status == eopayment.WAITING and can_poll_backend:
744
                    waiting_items[remote_item] = transaction.start_date
724 745

  
725 746
        # update remote_item.paid using paid_items
726 747
        for remote_item in remote_items:
......
729 750
            if remote_item.id in paid_items:
730 751
                remote_item.paid = True
731 752
                remote_item.payment_date = paid_items[remote_item.id]
753
            elif remote_item.id in waiting_items:
754
                remote_item.waiting_date = waiting_items[remote_item.id]
732 755

  
733 756

  
734 757
def status_label(status):
......
758 781
    amount = models.DecimalField(default=0, max_digits=7, decimal_places=2)
759 782

  
760 783
    RUNNING_STATUSES = [0, eopayment.WAITING, eopayment.RECEIVED]
784
    PAID_STATUSES = [eopayment.PAID, eopayment.ACCEPTED]
761 785

  
762 786
    def is_remote(self):
763 787
        return self.remote_items != ''
......
768 792
        return _('Anonymous User')
769 793

  
770 794
    def is_paid(self):
771
        return self.status in (eopayment.PAID, eopayment.ACCEPTED)
795
        return self.status in self.PAID_STATUSES
772 796

  
773 797
    def is_running(self):
774 798
        return self.status in self.RUNNING_STATUSES
......
1012 1036
        # list transactions :
1013 1037
        # * paid by the user
1014 1038
        # * or linked to a BasketItem of the user
1015
        return Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
1039
        qs = Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
1016 1040
            start_date__gte=timezone.now() - datetime.timedelta(days=7)
1017 1041
        )
1042
        for transaction in qs:
1043
            if transaction.can_poll_backend() and transaction.is_running():
1044
                transaction.poll_backend()
1045
        return qs
1018 1046

  
1019 1047
    def is_relevant(self, context):
1020 1048
        if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated):
combo/apps/lingo/templates/lingo/combo/item.html
48 48
        {% if item.no_online_payment_reason_details %}
49 49
        <div class="no-online-payment-reason"><span>{{ item.no_online_payment_reason_details }}</span></div>
50 50
        {% endif %}
51
        {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount %}
51
        {% if item.waiting_date and not item.paid %}
52
        <div class="paid paid-info">{% trans "Waiting for payment." %}</div>
53
        {% endif %}
54
        {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount and not item.waiting_date %}
52 55
        {% csrf_token %}
53 56
        {% if not user.is_authenticated %}
54 57
          <div class="email">
combo/apps/lingo/templates/lingo/combo/items.html
51 51
    {% if item.regie.is_remote %}
52 52
    <td>
53 53
      <a href="{% url 'view-item' regie_id=item.regie.pk item_crypto_id=item.crypto_id %}?page={{ cell.page.pk }}" rel="popup" class="icon-view">{% trans "View" %}
54
        {% if item.online_payment and item.amount >= item.regie.payment_min_amount %}{% trans "and pay" %}{% endif %}
54
          {% if item.online_payment and item.amount >= item.regie.payment_min_amount and not item.waiting_date %}{% trans "and pay" %}{% endif %}
55 55
      </a>
56 56
      {% if item.has_pdf %}
57 57
      <br/><a href="{% url 'download-item-pdf' regie_id=item.regie.pk item_crypto_id=item.crypto_id %}" class="icon-pdf"
combo/apps/lingo/views.py
58 58
    PaymentException,
59 59
    Regie,
60 60
    RemoteInvoiceException,
61
    RemoteItem,
61 62
    SelfDeclaredInvoicePayment,
62 63
    Transaction,
63 64
    TransactionOperation,
......
389 390
        if bool(len(items)) == bool(len(remote_items)):
390 391
            messages.error(request, _('Items to pay are missing or are not of the same type (local/remote).'))
391 392
            return HttpResponseRedirect(next_url)
393

  
394
        if (
395
            regie.payment_backend.can_poll_backend()
396
            and self.poll_for_newly_paid_or_still_running_transactions(regie, items, remote_items)
397
        ):
398
            messages.error(request, _('Some items are already paid or are being paid.'))
399
            return HttpResponseRedirect(next_url)
400

  
392 401
        if regie.can_pay_only_one_basket_item and (len(items) > 1 or len(remote_items) > 1):
393 402
            messages.error(request, _('This regie allows to pay only one item.'))
394 403
            return HttpResponseRedirect(next_url)
......
491 500

  
492 501
        raise NotImplementedError()
493 502

  
503
    def poll_for_newly_paid_or_still_running_transactions(self, regie, items, remote_items):
504
        '''Verify if any open transaction is not already paid.'''
505
        qs = Transaction.objects.filter(regie=regie, status__in=Transaction.RUNNING_STATUSES)
506
        if items:
507
            transactions = qs.filter(items__in=items)
508
        else:
509
            transactions = RemoteItem.transactions_for_remote_items(qs, remote_items)
510

  
511
        newly_paid_or_still_running = False
512
        for transaction in transactions:
513
            transaction.poll_backend()
514
            newly_paid_or_still_running |= transaction.is_paid() or transaction.is_running()
515
        return newly_paid_or_still_running
516

  
494 517

  
495 518
class PayView(PayMixin, View):
496 519
    def post(self, request, *args, **kwargs):
combo/public/views.py
586 586
    }
587 587
    ctx.update(getattr(request, 'extra_context_data', {}))
588 588
    modify_global_context(request, ctx)
589
    if getattr(settings, 'COMBO_TEST_SYNCHRONOUS', False):
590
        ctx['synchronous'] = True
589 591

  
590 592
    for cell in cells:
591 593
        if cell.modify_global_context:
tests/test_lingo_payment.py
26 26
    EXPIRED,
27 27
    BasketItem,
28 28
    LingoBasketCell,
29
    LingoRecentTransactionsCell,
29 30
    PaymentBackend,
30 31
    Regie,
31 32
    Transaction,
......
2090 2091
        ) as transaction_poll_backend:
2091 2092
            payment_backend.poll_backend()
2092 2093
            transaction_poll_backend.assert_called_once_with(transaction)
2094

  
2095
    @mock.patch('eopayment.payfip_ws.Payment.payment_status')
2096
    @mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/'))
2097
    def test_payfip_poll_for_newly_paid_transactions(
2098
        self, payment_request, payment_status, app, basket_page, mono_regie, user
2099
    ):
2100
        item = BasketItem.objects.create(
2101
            user=user,
2102
            regie=mono_regie,
2103
            amount=42,
2104
            subject='foo item',
2105
            request_data={'refdet': 'F20201030', 'exer': '2020'},
2106
        )
2107
        cell = LingoRecentTransactionsCell(page=basket_page, placeholder='content', order=1)
2108
        cell.save()
2109

  
2110
        login(app)
2111

  
2112
        # Try to pay
2113
        pay_resp = app.get('/test_basket_cell/')
2114
        assert 'foo item' in pay_resp
2115
        assert 'Running' not in pay_resp
2116
        resp = pay_resp.click('Pay')
2117
        # we are redirect to payfip
2118
        assert resp.location == 'https://payfip/'
2119

  
2120
        transaction = Transaction.objects.filter(items__in=[item]).get()
2121

  
2122
        # Simulate still running status on polling
2123
        payment_status.return_value = eopayment.common.PaymentResponse(
2124
            signed=True,
2125
            result=eopayment.WAITING,
2126
            order_id=transaction.order_id,
2127
        )
2128

  
2129
        # Try to pay again
2130
        resp = app.get('/test_basket_cell/')
2131
        assert 'foo item' not in resp
2132
        assert 'Pay' not in resp
2133
        assert 'Running' in resp
2134
        resp = pay_resp.click('Pay').follow()
2135
        assert 'Some items are already paid or' in resp
2136
        assert 'foo item' not in resp
2137
        assert 'Running' in resp
2138

  
2139
        # Simulate still paid status on polling
2140
        payment_status.return_value = eopayment.common.PaymentResponse(
2141
            signed=True,
2142
            result=eopayment.PAID,
2143
            order_id=transaction.order_id,
2144
        )
2145

  
2146
        # Try to pay again
2147
        resp = app.get('/test_basket_cell/')
2148
        assert 'foo item: 42.00' in resp
2149
        assert 'Pay' not in resp
2150
        assert 'Running' not in resp
tests/test_lingo_remote_regie.py
5 5
from decimal import Decimal
6 6

  
7 7
import eopayment
8
import httmock
8 9
import mock
9 10
import pytest
10 11
from django.apps import apps
......
32 33
from combo.data.models import Page
33 34
from combo.utils import aes_hex_encrypt, check_query
34 35

  
36
from .test_manager import login
37

  
35 38
pytestmark = pytest.mark.django_db
36 39

  
37 40

  
......
742 745
    assert 'http://localhost' in html_message
743 746
    assert mailoutbox[0].attachments[0][0] == '01.pdf'
744 747
    assert mailoutbox[0].attachments[0][2] == 'application/pdf'
748

  
749

  
750
@pytest.fixture
751
def remote_invoices_httmock():
752
    invoices = []
753
    invoice = {}
754

  
755
    netloc = 'remote.regie.example.com'
756

  
757
    @httmock.urlmatch(netloc=netloc, path='^/invoice/')
758
    def invoice_mock(url, request):
759
        return json.dumps({'err': 0, 'data': invoice})
760

  
761
    @httmock.urlmatch(netloc=netloc, path='^/invoices/')
762
    def invoices_mock(url, request):
763
        return json.dumps({'err': 0, 'data': invoices})
764

  
765
    context_manager = httmock.HTTMock(invoices_mock, invoice_mock)
766
    context_manager.url = 'https://%s/' % netloc
767
    context_manager.invoices = invoices
768
    context_manager.invoice = invoice
769
    with context_manager:
770
        yield context_manager
771

  
772

  
773
@mock.patch('eopayment.payfip_ws.Payment.payment_status')
774
@mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/'))
775
def test_remote_regie_poll_backend(
776
    payment_request, payment_status, app, remote_regie, settings, remote_invoices_httmock
777
):
778
    settings.COMBO_TEST_SYNCHRONOUS = True
779

  
780
    remote_invoices_httmock.invoices.extend(INVOICES)
781
    remote_invoices_httmock.invoice.update(INVOICES[0])
782
    remote_regie.webservice_url = remote_invoices_httmock.url
783
    remote_regie.save()
784
    # use payfip
785
    remote_regie.payment_backend.service = 'payfip_ws'
786
    remote_regie.payment_backend.save()
787

  
788
    User.objects.create_user('admin', password='admin', email='foo@example.com')
789
    page = Page.objects.create(title='xxx', slug='test_basket_cell', template_name='standard')
790
    ActiveItems.objects.create(regie='remote', page=page, placeholder='content', order=0)
791

  
792
    login(app)
793

  
794
    assert Transaction.objects.count() == 0
795

  
796
    resp = app.get('/test_basket_cell/?sync')
797
    assert 'F-2016-One' in resp
798

  
799
    resp = resp.click('pay', index=0)
800
    pay_resp = resp
801
    resp = resp.form.submit('Pay')
802

  
803
    transaction = Transaction.objects.get()
804
    assert transaction.status == 0
805

  
806
    payment_status.return_value = eopayment.common.PaymentResponse(
807
        signed=True,
808
        result=eopayment.WAITING,
809
        order_id=transaction.order_id,
810
    )
811

  
812
    assert payment_status.call_count == 0
813
    resp = app.get('/test_basket_cell/?sync')
814
    assert 'F-2016-One' in resp
815
    assert payment_status.call_count == 1
816
    transaction.refresh_from_db()
817
    assert transaction.status == eopayment.WAITING
818

  
819
    resp = resp.click('pay', index=0)
820
    assert 'Waiting for payment' in resp
821
    assert 'button' not in resp
822

  
823
    resp = pay_resp.form.submit('Pay').follow()
824
    assert 'Some items are already paid' in resp
825

  
826
    payment_status.return_value = eopayment.common.PaymentResponse(
827
        signed=True,
828
        result=eopayment.PAID,
829
        order_id=transaction.order_id,
830
    )
831

  
832
    resp = app.get('/test_basket_cell/?sync')
833
    assert 'F-2016-One' not in resp
834
    transaction.refresh_from_db()
835
    assert transaction.status == eopayment.PAID
745
-