diff --git a/chrono/agendas/migrations/0152_auto_20230331_0834.py b/chrono/agendas/migrations/0152_auto_20230331_0834.py new file mode 100644 index 00000000..c94491ea --- /dev/null +++ b/chrono/agendas/migrations/0152_auto_20230331_0834.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.16 on 2023-03-31 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('agendas', '0151_event_invoiced'), + ] + + operations = [ + migrations.AddField( + model_name='booking', + name='absence_callback_url', + field=models.URLField(blank=True), + ), + migrations.AddField( + model_name='booking', + name='presence_callback_url', + field=models.URLField(blank=True), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index c1ec8d68..d854e0df 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -20,6 +20,7 @@ import dataclasses import datetime import functools import itertools +import logging import math import sys import uuid @@ -1595,6 +1596,45 @@ class Event(models.Model): return self.checked = True self.save(update_fields=['checked']) + self.async_notify_checked() + + def async_notify_checked(self): + if 'uwsgi' in sys.modules: + from chrono.utils.spooler import event_notify_checked + + tenant = getattr(connection, 'tenant', None) + transaction.on_commit( + lambda: event_notify_checked.spool( + event_id=str(self.pk), domain=getattr(tenant, 'domain_url', None) + ) + ) + return + + self.notify_checked() + + def notify_checked(self): + for booking in self.booking_set.filter(user_was_present__isnull=False): + if booking.user_was_present is True and booking.presence_callback_url: + url = booking.presence_callback_url + elif booking.user_was_present is False and booking.absence_callback_url: + url = booking.absence_callback_url + else: + continue + payload = { + 'user_was_present': booking.user_was_present, + 'user_check_type_slug': booking.user_check_type_slug, + 'user_check_type_label': booking.user_check_type_label, + } + try: + response = requests_wrapper.post(url, json=payload, remote_service='auto', timeout=15) + if response and not response.ok: + logging.error( + 'error (HTTP %s) notifying checked booking (%s)', response.status_code, booking.id + ) + except requests.Timeout: + logging.error('error (timeout) notifying checked booking (%s)', booking.id) + except Exception as e: # noqa pylint: disable=broad-except + logging.error('error (%s) notifying checked booking (%s)', e, booking.id) def in_bookable_period(self, bypass_delays=False): if self.publication_datetime and now() < self.publication_datetime: @@ -2163,6 +2203,8 @@ class Booking(models.Model): form_url = models.URLField(blank=True) backoffice_url = models.URLField(blank=True) cancel_callback_url = models.URLField(blank=True) + presence_callback_url = models.URLField(blank=True) + absence_callback_url = models.URLField(blank=True) color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings') @property diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py index 4f926ca6..feba6530 100644 --- a/chrono/api/serializers.py +++ b/chrono/api/serializers.py @@ -82,6 +82,8 @@ class FillSlotSerializer(serializers.Serializer): form_url = serializers.CharField(max_length=250, allow_blank=True) backoffice_url = serializers.URLField(allow_blank=True) cancel_callback_url = serializers.URLField(allow_blank=True) + presence_callback_url = serializers.URLField(allow_blank=True) + absence_callback_url = serializers.URLField(allow_blank=True) count = serializers.IntegerField(min_value=1) cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True) force_waiting_list = serializers.BooleanField(default=False) diff --git a/chrono/api/views.py b/chrono/api/views.py index 345f4bbf..af498ff0 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -727,6 +727,8 @@ def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_li form_url=translate_to_publik_url(payload.get('form_url', '')), backoffice_url=translate_to_publik_url(payload.get('backoffice_url', '')), cancel_callback_url=translate_to_publik_url(payload.get('cancel_callback_url', '')), + presence_callback_url=translate_to_publik_url(payload.get('presence_callback_url', '')), + absence_callback_url=translate_to_publik_url(payload.get('absence_callback_url', '')), user_display_label=payload.get('user_display_label', ''), extra_emails=payload.get('extra_emails', []), extra_phone_numbers=payload.get('extra_phone_numbers', []), @@ -3064,6 +3066,7 @@ class EventCheck(APIView): if not event.checked: event.checked = True event.save(update_fields=['checked']) + event.async_notify_checked() response = { 'err': 0, } diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 6b8e10bc..c5874057 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -2749,6 +2749,7 @@ class EventCheckedView(EventCheckMixin, ViewableAgendaMixin, View): if not self.event.checked: self.event.checked = True self.event.save(update_fields=['checked']) + self.event.async_notify_checked() return self.response(request) diff --git a/chrono/utils/spooler.py b/chrono/utils/spooler.py index 77176a21..38a04dc2 100644 --- a/chrono/utils/spooler.py +++ b/chrono/utils/spooler.py @@ -17,7 +17,7 @@ from django.db import connection from uwsgidecorators import spool # pylint: disable=import-error -from chrono.agendas.models import ICSError, TimePeriodExceptionSource +from chrono.agendas.models import Event, ICSError, TimePeriodExceptionSource def set_connection(domain): @@ -55,3 +55,17 @@ def refresh_exceptions_from_settings(args): return source.refresh_from_settings() + + +@spool +def event_notify_checked(args): + if args.get('domain'): + # multitenant installation + set_connection(args['domain']) + + try: + event = Event.objects.get(pk=args['event_id']) + except Event.DoesNotExist: + return + + event.notify_checked() diff --git a/tests/api/fillslot/test_all.py b/tests/api/fillslot/test_all.py index 78b6fa18..5bf8f19b 100644 --- a/tests/api/fillslot/test_all.py +++ b/tests/api/fillslot/test_all.py @@ -80,6 +80,8 @@ def test_booking_api(app, user): 'form_url': 'http://example.net/', 'extra_emails': ['baz@baz.com', 'hop@hop.com'], 'extra_phone_numbers': ['+33123456789', '+33123456789'], + 'presence_callback_url': 'http://example.net/jump/trigger2/', + 'absence_callback_url': 'http://example.net/jump/trigger3/', }, ) booking = Booking.objects.get(id=resp.json['booking_id']) @@ -88,6 +90,8 @@ def test_booking_api(app, user): assert booking.user_last_name == 'bar' assert booking.backoffice_url == 'http://example.net/' assert booking.cancel_callback_url == 'http://example.net/jump/trigger/' + assert booking.presence_callback_url == 'http://example.net/jump/trigger2/' + assert booking.absence_callback_url == 'http://example.net/jump/trigger3/' assert booking.user_email == 'bar@bar.com' assert booking.user_phone_number == '+33 (0) 6 12 34 56 78' assert booking.form_url == 'http://example.net/' diff --git a/tests/api/test_event.py b/tests/api/test_event.py index 40d5d51d..ba54a2bf 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -1,4 +1,5 @@ import datetime +from unittest import mock import pytest from django.db import connection @@ -189,6 +190,49 @@ def test_event_checked(app, user): app.post('/api/agenda/%s/check/%s/' % (agenda.slug, event.slug), status=404) +def test_event_notify_checked(app, user): + agenda = Agenda.objects.create(label='Events', kind='events') + event = Event.objects.create( + label='xyz', + start_datetime=now() - datetime.timedelta(days=1), + places=10, + agenda=agenda, + ) + assert event.checked is False + + for i in range(8): + user_was_present = None + if i < 3: + user_was_present = True + elif i < 7: + user_was_present = False + Booking.objects.create( + event=event, + user_was_present=user_was_present, + presence_callback_url='https://example.invalid/presence/%s' % i, + absence_callback_url='https://example.invalid/absence/%s' % i, + ) + + app.authorization = ('Basic', ('john.doe', 'password')) + + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + app.post('/api/agenda/%s/check/%s/' % (agenda.slug, event.slug)) + event.refresh_from_db() + assert event.checked is True + + assert {x[0][0].url for x in mock_send.call_args_list} == { + 'https://example.invalid/presence/0', + 'https://example.invalid/presence/1', + 'https://example.invalid/presence/2', + 'https://example.invalid/absence/3', + 'https://example.invalid/absence/4', + 'https://example.invalid/absence/5', + 'https://example.invalid/absence/6', + } + + @pytest.mark.parametrize( 'days_in, days_out, err_msg', [ diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py index cbd9041f..10cc1a95 100644 --- a/tests/manager/test_event.py +++ b/tests/manager/test_event.py @@ -1,5 +1,6 @@ import codecs import datetime +import json from unittest import mock import pytest @@ -1798,6 +1799,53 @@ def test_event_checked(app, admin_user): assert 'Invoiced' in resp +def test_event_notify_checked(app, admin_user): + agenda = Agenda.objects.create(label='Events', kind='events', booking_check_filters='foo,bar') + event = Event.objects.create( + label='xyz', + start_datetime=now() - datetime.timedelta(days=1), + places=10, + agenda=agenda, + ) + login(app) + + for i in range(8): + user_was_present = None + if i < 3: + user_was_present = True + elif i < 7: + user_was_present = False + Booking.objects.create( + event=event, + user_was_present=user_was_present, + presence_callback_url='https://example.invalid/presence/%s' % i, + absence_callback_url='https://example.invalid/absence/%s' % i, + ) + resp = app.get('/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk)) + + with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: + mock_response = mock.Mock(status_code=200) + mock_send.return_value = mock_response + resp = app.post( + '/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk), + params={'csrfmiddlewaretoken': resp.context['csrf_token']}, + ) + assert {x[0][0].url for x in mock_send.call_args_list} == { + 'https://example.invalid/presence/0', + 'https://example.invalid/presence/1', + 'https://example.invalid/presence/2', + 'https://example.invalid/absence/3', + 'https://example.invalid/absence/4', + 'https://example.invalid/absence/5', + 'https://example.invalid/absence/6', + } + assert set(json.loads(mock_send.call_args_list[0][0][0].body).keys()) == { + 'user_check_type_label', + 'user_check_type_slug', + 'user_was_present', + } + + @mock.patch('chrono.manager.forms.get_agenda_check_types') def test_event_check_filters(check_types, app, admin_user): check_types.return_value = [