diff --git a/lingo/agendas/chrono.py b/lingo/agendas/chrono.py index c2ed78b..9fc5f8d 100644 --- a/lingo/agendas/chrono.py +++ b/lingo/agendas/chrono.py @@ -38,13 +38,14 @@ def get_chrono_service(): return list(settings.KNOWN_SERVICES.get('chrono').values())[0] -def get_chrono_json(path, log_errors=True): +def get_chrono_json(path, params=None, log_errors=True): chrono_site = get_chrono_service() if chrono_site is None: return try: response = requests.get( path, + params=params or {}, remote_service=chrono_site, without_user=True, headers={'accept': 'application/json'}, @@ -111,14 +112,24 @@ def refresh_agendas(): def get_event(event_slug): - result = get_chrono_json('api/agendas/events/?slots=%s' % event_slug) + return get_events( + [event_slug], + error_message=_('Unable to get event details'), + error_message_with_details=_('Unable to get event details (%s)'), + )[0] + + +def get_events(event_slugs, error_message=None, error_message_with_details=None): + error_message = error_message or _('Unable to get events details') + error_message_with_details = error_message_with_details or _('Unable to get events details (%s)') + result = get_chrono_json('api/agendas/events/', params={'slots': event_slugs}) if not result: - raise ChronoError(_('Unable to get event details')) + raise ChronoError(error_message) if result.get('err'): - raise ChronoError(_('Unable to get event details (%s)') % result['err_desc']) + raise ChronoError(error_message_with_details % result['err_desc']) if not result.get('data'): - raise ChronoError(_('Unable to get event details')) - return result['data'][0] + raise ChronoError(error_message) + return result['data'] def get_subscriptions(agenda_slug, user_external_id): diff --git a/lingo/api/serializers.py b/lingo/api/serializers.py new file mode 100644 index 0000000..8f09fcc --- /dev/null +++ b/lingo/api/serializers.py @@ -0,0 +1,146 @@ +# lingo - payment and billing system +# Copyright (C) 2022 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from lingo.agendas.chrono import ChronoError, get_events, get_subscriptions +from lingo.agendas.models import Agenda +from lingo.pricing.models import AgendaPricing, PricingError + + +class CommaSeparatedStringField(serializers.ListField): + def get_value(self, dictionary): + return super(serializers.ListField, self).get_value(dictionary) + + def to_internal_value(self, data): + data = [s.strip() for s in data.split(',') if s.strip()] + return super().to_internal_value(data) + + +class PricingComputeSerializer(serializers.Serializer): + slots = CommaSeparatedStringField( + required=True, child=serializers.CharField(max_length=160, allow_blank=False) + ) + user_external_id = serializers.CharField(required=True, max_length=250) + adult_external_id = serializers.CharField(required=True, max_length=250) + + agenda_slugs = [] + agendas = {} + serialized_events = {} + event_subscriptions = {} + + def validate_slots(self, value): + self.agendas = {a.slug: a for a in Agenda.objects.all()} + allowed_agenda_slugs = self.agendas.keys() + agenda_slugs = set() + for slot in value: + try: + agenda_slug, event_slug = slot.split('@') + except ValueError: + raise ValidationError(_('Invalid format for slot %s') % slot) + if not agenda_slug: + raise ValidationError(_('Missing agenda slug in slot %s') % slot) + if not event_slug: + raise ValidationError(_('Missing event slug in slot %s') % slot) + agenda_slugs.add(agenda_slug) + extra_agendas = agenda_slugs - set(allowed_agenda_slugs) + if extra_agendas: + extra_agendas = ', '.join(sorted(extra_agendas)) + raise ValidationError(_('Unknown agendas: %s') % extra_agendas) + self.agenda_slugs = sorted(agenda_slugs) + + try: + serialized_events = get_events(value) + except ChronoError as e: + raise ValidationError(e) + else: + for serialized_event in serialized_events: + event_slug = '%s@%s' % (serialized_event['agenda'], serialized_event['slug']) + self.serialized_events[event_slug] = serialized_event + + return value + + def get_subscriptions(self, user_external_id): + agenda_subscriptions = {} + for agenda_slug in self.agenda_slugs: + try: + agenda_subscriptions[agenda_slug] = get_subscriptions(agenda_slug, user_external_id) + except ChronoError as e: + raise ValidationError({'user_external_id': e}) + + self.event_subscriptions = {} + for event_slug, serialized_event in self.serialized_events.items(): + start_date = datetime.datetime.fromisoformat(serialized_event['start_datetime']).date() + end_date = start_date + datetime.timedelta(days=1) + agenda_slug = serialized_event['agenda'] + event_subscription = None + for subscription in agenda_subscriptions[agenda_slug]: + sub_start_date = datetime.date.fromisoformat(subscription['date_start']) + sub_end_date = datetime.date.fromisoformat(subscription['date_end']) + if sub_start_date >= end_date: + continue + if sub_end_date <= start_date: + continue + event_subscription = subscription + break + if event_subscription is None: + raise ValidationError( + {'user_external_id': _('No subscription found for event %s') % event_slug} + ) + self.event_subscriptions[event_slug] = event_subscription + + def validate(self, attrs): + super().validate(attrs) + if attrs.get('user_external_id') and self.serialized_events: + self.get_subscriptions(attrs['user_external_id']) + return attrs + + def compute(self, request): + if not self.serialized_events or not self.event_subscriptions: + return + result = [] + event_slugs = sorted(self.serialized_events.keys()) + for event_slug in event_slugs: + serialized_event = self.serialized_events[event_slug] + try: + pricing_data = AgendaPricing.get_pricing_data( + request=request, + agenda=self.agendas[serialized_event['agenda']], + event=serialized_event, + subscription=self.event_subscriptions[event_slug], + check_status={ + 'status': 'presence', + 'check_type': None, + }, + booking={}, + user_external_id=self.validated_data['user_external_id'], + adult_external_id=self.validated_data['adult_external_id'], + ) + result.append( + { + 'event': event_slug, + 'pricing_data': pricing_data, + } + ) + except PricingError as e: + result.append({'event': event_slug, 'error': e.details}) + + result = sorted(result, key=lambda d: d['event']) + return result diff --git a/lingo/api/urls.py b/lingo/api/urls.py index d2d6893..aadb581 100644 --- a/lingo/api/urls.py +++ b/lingo/api/urls.py @@ -24,4 +24,9 @@ urlpatterns = [ views.agenda_check_type_list, name='api-agenda-check-types', ), + url( + r'^pricing/compute/$', + views.pricing_compute, + name='api-pricing-compute', + ), ] diff --git a/lingo/api/views.py b/lingo/api/views.py index b9da3be..16ac0ca 100644 --- a/lingo/api/views.py +++ b/lingo/api/views.py @@ -15,10 +15,13 @@ # along with this program. If not, see . from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_noop as N_ +from rest_framework import permissions from rest_framework.views import APIView from lingo.agendas.models import Agenda -from lingo.api.utils import Response +from lingo.api import serializers +from lingo.api.utils import APIErrorBadRequest, Response class AgendaCheckTypeList(APIView): @@ -37,3 +40,18 @@ class AgendaCheckTypeList(APIView): agenda_check_type_list = AgendaCheckTypeList.as_view() + + +class PricingCompute(APIView): + permission_classes = (permissions.IsAuthenticated,) + serializer_class = serializers.PricingComputeSerializer + + def get(self, request, format=None): + serializer = self.serializer_class(data=request.query_params) + if not serializer.is_valid(): + raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) + + return Response({'data': serializer.compute(self.request)}) + + +pricing_compute = PricingCompute.as_view() diff --git a/lingo/settings.py b/lingo/settings.py index 37b5325..1dae95b 100644 --- a/lingo/settings.py +++ b/lingo/settings.py @@ -54,6 +54,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'eopayment', 'gadjo', + 'rest_framework', 'lingo.agendas', 'lingo.api', 'lingo.manager', diff --git a/tests/agendas/test_chrono.py b/tests/agendas/test_chrono.py index 33cb47e..738438d 100644 --- a/tests/agendas/test_chrono.py +++ b/tests/agendas/test_chrono.py @@ -9,6 +9,7 @@ from lingo.agendas.chrono import ( ChronoError, collect_agenda_data, get_event, + get_events, get_subscriptions, refresh_agendas, ) @@ -206,7 +207,8 @@ def test_get_event(): with pytest.raises(ChronoError) as e: get_event('foo') assert str(e.value) == 'Unable to get event details' - assert requests_get.call_args_list[0][0] == ('api/agendas/events/?slots=foo',) + assert requests_get.call_args_list[0][0] == ('api/agendas/events/',) + assert requests_get.call_args_list[0][1]['params']['slots'] == ['foo'] assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' data = {'data': ['foo']} @@ -220,6 +222,63 @@ def test_get_event(): assert get_event('foo') == 'foo' +def test_get_events_no_service(settings): + settings.KNOWN_SERVICES = {} + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + settings.KNOWN_SERVICES = {'other': []} + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + +def test_get_events(): + with mock.patch('requests.Session.get') as requests_get: + requests_get.side_effect = ConnectionError() + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + with mock.patch('requests.Session.get') as requests_get: + mock_resp = Response() + mock_resp.status_code = 500 + requests_get.return_value = mock_resp + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + with mock.patch('requests.Session.get') as requests_get: + mock_resp = Response() + mock_resp.status_code = 404 + requests_get.return_value = mock_resp + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps({'foo': 'bar'})) + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + + data = {'data': []} + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) + with pytest.raises(ChronoError) as e: + get_events(['foo', 'bar']) + assert str(e.value) == 'Unable to get events details' + assert requests_get.call_args_list[0][0] == ('api/agendas/events/',) + assert requests_get.call_args_list[0][1]['params']['slots'] == ['foo', 'bar'] + assert requests_get.call_args_list[0][1]['remote_service']['url'] == 'http://chrono.example.org' + + data = {'data': ['foo', 'bar']} + with mock.patch('requests.Session.get') as requests_get: + requests_get.return_value = MockedRequestResponse(content=json.dumps(data)) + assert get_events(['foo', 'bar']) == ['foo', 'bar'] + + def test_get_subscriptions_no_service(settings): settings.KNOWN_SERVICES = {} with pytest.raises(ChronoError) as e: diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..61d1a07 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,14 @@ +import pytest +from django.contrib.auth import get_user_model + +User = get_user_model() + + +@pytest.fixture +def user(): + user = User.objects.create( + username='john.doe', first_name='John', last_name='Doe', email='john.doe@example.net' + ) + user.set_password('password') + user.save() + return user diff --git a/tests/api/test_pricing.py b/tests/api/test_pricing.py new file mode 100644 index 0000000..e965469 --- /dev/null +++ b/tests/api/test_pricing.py @@ -0,0 +1,314 @@ +from unittest import mock + +import pytest + +from lingo.agendas.chrono import ChronoError +from lingo.agendas.models import Agenda +from lingo.pricing.models import PricingError + +pytestmark = pytest.mark.django_db + + +def test_pricing_compute_params(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + + # missing slots + resp = app.get( + '/api/pricing/compute/', + params={'user_external_id': 'user:1', 'adult_external_id': 'adult:1'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['This field is required.'] + + # missing user_external_id + resp = app.get( + '/api/pricing/compute/', + params={'slots': 'foo@foo', 'adult_external_id': 'adult:1'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['user_external_id'] == ['This field is required.'] + + # missing adult_external_id + resp = app.get( + '/api/pricing/compute/', + params={'slots': 'foo@foo', 'user_external_id': 'user:1'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['adult_external_id'] == ['This field is required.'] + + +def test_pricing_compute_slots(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + + # bad slot format + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['Invalid format for slot event-bar-slug'] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': '@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['Missing agenda slug in slot @event-bar-slug'] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['Missing event slug in slot agenda@'] + + # unknown agenda + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug, agenda2@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['Unknown agendas: agenda, agenda2'] + + +@mock.patch('lingo.api.serializers.get_events') +def test_pricing_compute_events_error(mock_events, app, user): + Agenda.objects.create(label='Agenda') + app.authorization = ('Basic', ('john.doe', 'password')) + + mock_events.side_effect = ChronoError('foo bar') + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['slots'] == ['foo bar'] + + +@mock.patch('lingo.api.serializers.get_events') +@mock.patch('lingo.api.serializers.get_subscriptions') +def test_pricing_compute_subscriptions_error(mock_subscriptions, mock_events, app, user): + Agenda.objects.create(label='Agenda') + app.authorization = ('Basic', ('john.doe', 'password')) + + mock_events.return_value = [ + {'start_datetime': '2021-09-01T12:00:00+02:00', 'agenda': 'agenda', 'slug': 'event-bar-slug'} + ] + mock_subscriptions.side_effect = ChronoError('foo baz') + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['user_external_id'] == ['foo baz'] + + +@mock.patch('lingo.api.serializers.get_events') +@mock.patch('lingo.api.serializers.get_subscriptions') +@mock.patch('lingo.pricing.models.AgendaPricing.get_pricing_data') +def test_pricing_compute(mock_pricing_data, mock_subscriptions, mock_events, app, user): + agenda = Agenda.objects.create(label='Agenda') + agenda2 = Agenda.objects.create(label='Agenda2') + app.authorization = ('Basic', ('john.doe', 'password')) + + mock_events.return_value = [ + {'start_datetime': '2021-09-01T12:00:00+02:00', 'agenda': 'agenda', 'slug': 'event-bar-slug'} + ] + mock_subscriptions.return_value = [] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['user_external_id'] == [ + 'No subscription found for event agenda@event-bar-slug' + ] + assert mock_pricing_data.call_args_list == [] + assert mock_subscriptions.call_args_list == [mock.call('agenda', 'user:1')] + + mock_subscriptions.reset_mock() + mock_subscriptions.return_value = [ + { + 'date_start': '2021-08-01', + 'date_end': '2021-09-01', + }, + { + 'date_start': '2021-09-02', + 'date_end': '2021-09-03', + }, + ] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['user_external_id'] == [ + 'No subscription found for event agenda@event-bar-slug' + ] + assert mock_pricing_data.call_args_list == [] + assert mock_subscriptions.call_args_list == [mock.call('agenda', 'user:1')] + + mock_events.return_value = [ + {'start_datetime': '2021-09-02T12:00:00+02:00', 'agenda': 'agenda', 'slug': 'event-bar-slug'}, + {'start_datetime': '2021-09-01T12:00:00+02:00', 'agenda': 'agenda2', 'slug': 'event-baz-slug'}, + ] + mock_subscriptions.reset_mock() + mock_subscriptions.return_value = [ + { + 'date_start': '2021-08-01', + 'date_end': '2021-09-01', + }, + { + 'date_start': '2021-09-02', + 'date_end': '2021-09-03', + }, + ] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug, agenda2@event-baz-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['user_external_id'] == [ + 'No subscription found for event agenda2@event-baz-slug' + ] + assert mock_pricing_data.call_args_list == [] + assert mock_subscriptions.call_args_list == [ + mock.call('agenda', 'user:1'), + mock.call('agenda2', 'user:1'), + ] + + mock_subscriptions.return_value = [ + { + 'date_start': '2021-08-01', + 'date_end': '2021-09-01', + }, + { + 'date_start': '2021-09-01', + 'date_end': '2021-09-02', + }, + { + 'date_start': '2021-09-02', + 'date_end': '2021-09-03', + }, + ] + mock_pricing_data.side_effect = [ + {'foo': 'baz'}, + {'foo': 'bar'}, + ] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug, agenda2@event-baz-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + ) + assert resp.json['data'] == [ + {'event': 'agenda2@event-baz-slug', 'pricing_data': {'foo': 'baz'}}, + {'event': 'agenda@event-bar-slug', 'pricing_data': {'foo': 'bar'}}, + ] + assert mock_pricing_data.call_args_list == [ + mock.call( + request=mock.ANY, + agenda=agenda2, + event={ + 'start_datetime': '2021-09-01T12:00:00+02:00', + 'agenda': 'agenda2', + 'slug': 'event-baz-slug', + }, + subscription={'date_start': '2021-09-01', 'date_end': '2021-09-02'}, + check_status={'status': 'presence', 'check_type': None}, + booking={}, + user_external_id='user:1', + adult_external_id='adult:1', + ), + mock.call( + request=mock.ANY, + agenda=agenda, + event={ + 'start_datetime': '2021-09-02T12:00:00+02:00', + 'agenda': 'agenda', + 'slug': 'event-bar-slug', + }, + subscription={'date_start': '2021-09-02', 'date_end': '2021-09-03'}, + check_status={'status': 'presence', 'check_type': None}, + booking={}, + user_external_id='user:1', + adult_external_id='adult:1', + ), + ] + + mock_pricing_data.side_effect = [ + PricingError(details={'foo': 'error'}), + {'foo': 'bar'}, + ] + resp = app.get( + '/api/pricing/compute/', + params={ + 'slots': 'agenda@event-bar-slug, agenda2@event-baz-slug', + 'user_external_id': 'user:1', + 'adult_external_id': 'adult:1', + }, + ) + assert resp.json['data'] == [ + {'event': 'agenda2@event-baz-slug', 'error': {'foo': 'error'}}, + {'event': 'agenda@event-bar-slug', 'pricing_data': {'foo': 'bar'}}, + ]