diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 36ef2fd1..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: diff --git a/chrono/api/views.py b/chrono/api/views.py index fd99302a..af498ff0 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -3066,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/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 = [