From c7319f2c3cde69772978d5aabcc5dfa6a4498ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Fri, 17 Mar 2023 15:50:46 +0100 Subject: [PATCH 1/2] api: mark events as invoiced (#75415) --- .../agendas/migrations/0151_event_invoiced.py | 15 + chrono/agendas/models.py | 1 + chrono/api/serializers.py | 15 +- chrono/api/urls.py | 5 + chrono/api/views.py | 30 ++ tests/api/test_event.py | 286 ++++++++++++++++++ 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 chrono/agendas/migrations/0151_event_invoiced.py diff --git a/chrono/agendas/migrations/0151_event_invoiced.py b/chrono/agendas/migrations/0151_event_invoiced.py new file mode 100644 index 00000000..67ee4f72 --- /dev/null +++ b/chrono/agendas/migrations/0151_event_invoiced.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('agendas', '0150_event_check_locked'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='invoiced', + field=models.BooleanField(default=False), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 4c3d1cb1..c1ec8d68 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -1522,6 +1522,7 @@ class Event(models.Model): cancellation_scheduled = models.BooleanField(default=False) checked = models.BooleanField(default=False) check_locked = models.BooleanField(default=False) + invoiced = models.BooleanField(default=False) meeting_type = models.ForeignKey(MeetingType, null=True, on_delete=models.CASCADE) desk = models.ForeignKey('Desk', null=True, on_delete=models.CASCADE) resources = models.ManyToManyField('Resource') diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py index 13888567..4f926ca6 100644 --- a/chrono/api/serializers.py +++ b/chrono/api/serializers.py @@ -169,6 +169,18 @@ class MultipleAgendasEventsCheckLockSerializer(AgendaSlugsMixin, DateRangeMixin, return get_objects_from_slugs(value, qs=self.get_agenda_qs()) +class MultipleAgendasEventsInvoicedSerializer(AgendaSlugsMixin, DateRangeMixin, serializers.Serializer): + invoiced = serializers.BooleanField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in ['agendas', 'date_start', 'date_end', 'invoiced']: + self.fields[field].required = True + + def validate_agendas(self, value): + return get_objects_from_slugs(value, qs=self.get_agenda_qs()) + + class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer): include_booked_events_detail = serializers.BooleanField(default=False) check_overlaps = CommaSeparatedStringField( @@ -451,8 +463,9 @@ class EventSerializer(serializers.ModelSerializer): 'agenda', 'checked', 'check_locked', + 'invoiced', ] - read_only_fields = ['slug', 'checked', 'check_locked'] + read_only_fields = ['slug', 'checked', 'check_locked', 'invoiced'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/chrono/api/urls.py b/chrono/api/urls.py index e46c7f5e..ec58381c 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -43,6 +43,11 @@ urlpatterns = [ views.agendas_events_check_lock, name='api-agendas-events-check-lock', ), + path( + 'agendas/events/invoiced/', + views.agendas_events_invoiced, + name='api-agendas-events-invoiced', + ), re_path(r'^agenda/(?P[\w-]+)/$', views.agenda), re_path( r'^agenda/(?P[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes' diff --git a/chrono/api/views.py b/chrono/api/views.py index 2ea2c747..345f4bbf 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -503,6 +503,7 @@ def get_event_detail( 'duration': event.duration, 'checked': event.checked, 'check_locked': event.check_locked, + 'invoiced': event.invoiced, } for key, value in event.get_custom_fields().items(): details['custom_field_%s' % key] = value @@ -2326,6 +2327,35 @@ class MultipleAgendasEventsCheckLock(APIView): agendas_events_check_lock = MultipleAgendasEventsCheckLock.as_view() +class MultipleAgendasEventsInvoiced(APIView): + permission_classes = (permissions.IsAuthenticated,) + serializer_class = serializers.MultipleAgendasEventsInvoicedSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + + if not serializer.is_valid(): + raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors, err=1) + agendas = serializer.validated_data['agendas'] + date_start = serializer.validated_data['date_start'] + date_end = serializer.validated_data['date_end'] + invoiced = serializer.validated_data['invoiced'] + + events = Event.objects.filter( + agenda__in=agendas, + recurrence_days__isnull=True, + cancelled=False, + start_datetime__gte=date_start, + start_datetime__lt=date_end, + ) + events.update(invoiced=invoiced) + + return Response({'err': 0}) + + +agendas_events_invoiced = MultipleAgendasEventsInvoiced.as_view() + + class SubscriptionFilter(filters.FilterSet): date_start = filters.DateFilter(method='do_nothing') date_end = filters.DateFilter(method='do_nothing') diff --git a/tests/api/test_event.py b/tests/api/test_event.py index d30c0864..40d5d51d 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -55,6 +55,7 @@ def test_status(app, user): 'duration': None, 'checked': False, 'check_locked': False, + 'invoiced': False, 'api': { 'bookings_url': 'http://testserver/api/agenda/foo-bar/bookings/event-slug/', 'fillslot_url': 'http://testserver/api/agenda/foo-bar/fillslot/event-slug/', @@ -94,6 +95,7 @@ def test_status(app, user): 'duration': None, 'checked': False, 'check_locked': False, + 'invoiced': False, 'custom_field_text': 'foo', 'custom_field_textarea': 'foo bar', 'custom_field_bool': True, @@ -883,6 +885,7 @@ def test_events(app, user): agenda=agenda2, checked=True, check_locked=True, + invoiced=True, ) # cancelled event, not returned Event.objects.create( @@ -946,6 +949,7 @@ def test_events(app, user): 'checked': True, 'description': None, 'duration': None, + 'invoiced': True, 'label': 'Event Label', 'places': 10, 'pricing': None, @@ -965,6 +969,7 @@ def test_events(app, user): 'checked': False, 'description': None, 'duration': None, + 'invoiced': False, 'label': 'Recurring Event Label', 'places': 10, 'pricing': None, @@ -984,6 +989,7 @@ def test_events(app, user): 'checked': False, 'description': None, 'duration': None, + 'invoiced': False, 'label': 'Recurring Event Label', 'places': 10, 'pricing': None, @@ -1311,6 +1317,7 @@ def test_events_check_status_events(app, user): agenda=agenda, checked=True, check_locked=True, + invoiced=True, ) # not checked event notchecked_event = Event.objects.create( @@ -1378,6 +1385,7 @@ def test_events_check_status_events(app, user): 'primary_event': None, 'check_locked': False, 'checked': False, + 'invoiced': False, 'custom_field_bool': None, 'custom_field_text': '', 'custom_field_textarea': '', @@ -1404,6 +1412,7 @@ def test_events_check_status_events(app, user): 'primary_event': None, 'check_locked': True, 'checked': True, + 'invoiced': True, 'custom_field_bool': None, 'custom_field_text': '', 'custom_field_textarea': '', @@ -1445,6 +1454,7 @@ def test_events_check_status_events(app, user): 'primary_event': recurring_event.slug, 'check_locked': False, 'checked': True, + 'invoiced': False, 'custom_field_text': 'foo', 'custom_field_textarea': 'foo bar', 'custom_field_bool': True, @@ -1887,3 +1897,279 @@ def test_events_check_lock_date_filter(app, user, event_date, expected): app.post_json(url, params=params) event.refresh_from_db() assert event.check_locked == expected + + +def test_events_invoiced_params(app, user): + app.authorization = ('Basic', ('john.doe', 'password')) + + # missing invoiced + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={'agendas': 'foo', 'date_start': '2022-05-01', 'date_end': '2022-06-01'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['invoiced'] == ['This field is required.'] + + # missing agendas + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={'invoiced': True, 'date_start': '2022-05-01', 'date_end': '2022-06-01'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['agendas'] == ['This field is required.'] + + # unknown agenda + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={ + 'invoiced': True, + 'agendas': 'foo, bar', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['agendas'] == ['invalid slugs: bar, foo'] + Agenda.objects.create(label='Foo') + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={ + 'invoiced': True, + 'agendas': 'foo, bar', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['agendas'] == ['invalid slugs: bar'] + + # wrong kind + wrong_agenda = Agenda.objects.create(label='Bar') + for kind in ['meetings', 'virtual']: + wrong_agenda.kind = kind + wrong_agenda.save() + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={ + 'invoiced': True, + 'agendas': 'foo, bar', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + }, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['agendas'] == ['invalid slugs: bar'] + + # missing date_start + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={'invoiced': True, 'agendas': 'foo', 'date_end': '2022-06-01'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['date_start'] == ['This field is required.'] + + # missing date_end + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={'invoiced': True, 'agendas': 'foo', 'date_start': '2022-05-01'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert resp.json['errors']['date_end'] == ['This field is required.'] + + # bad date format + resp = app.post_json( + '/api/agendas/events/invoiced/', + params={'invoiced': True, 'agendas': 'foo', 'date_start': 'wrong', 'date_end': 'wrong'}, + status=400, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'invalid payload' + assert 'wrong format' in resp.json['errors']['date_start'][0] + assert 'wrong format' in resp.json['errors']['date_end'][0] + + +@pytest.mark.freeze_time('2022-05-30 14:00') +def test_events_invoiced(app, user): + agenda = Agenda.objects.create(label='Foo') + event = Event.objects.create( + slug='event-slug', + label='Event Label', + start_datetime=now(), + places=10, + agenda=agenda, + checked=True, + ) + + app.authorization = ('Basic', ('john.doe', 'password')) + url = '/api/agendas/events/invoiced/' + params = { + 'invoiced': True, + 'agendas': 'foo', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + } + resp = app.post_json(url, params=params) + assert resp.json['err'] == 0 + event.refresh_from_db() + assert event.invoiced is True + + params['invoiced'] = False + resp = app.post_json(url, params=params) + assert resp.json['err'] == 0 + event.refresh_from_db() + assert event.invoiced is False + + +@pytest.mark.freeze_time('2022-05-30 14:00') +def test_events_invoiced_events(app, user): + events_type = EventsType.objects.create( + label='Foo', + ) + agenda = Agenda.objects.create(label='Foo', events_type=events_type) + start_datetime = now() + # recurring event + recurring_event = Event.objects.create( + slug='recurring-event-slug', + label='Recurring Event Label', + start_datetime=start_datetime, + recurrence_days=[start_datetime.weekday()], + recurrence_end_date=start_datetime + datetime.timedelta(days=7), + places=10, + agenda=agenda, + ) + recurring_event.create_all_recurrences() + first_event = recurring_event.recurrences.get() + event = Event.objects.create( + slug='event-slug', + label='Event Label', + start_datetime=start_datetime - datetime.timedelta(days=1), + places=10, + agenda=agenda, + ) + # cancelled event, not updated + cancelled_event = Event.objects.create( + slug='cancelled', + label='Cancelled', + start_datetime=start_datetime, + places=10, + agenda=agenda, + cancelled=True, + ) + + app.authorization = ('Basic', ('john.doe', 'password')) + url = '/api/agendas/events/invoiced/' + params = { + 'invoiced': True, + 'agendas': 'foo', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + } + resp = app.post_json(url, params=params) + assert resp.json['err'] == 0 + recurring_event.refresh_from_db() + assert recurring_event.invoiced is False + first_event.refresh_from_db() + assert first_event.invoiced is True + event.refresh_from_db() + assert event.invoiced is True + cancelled_event.refresh_from_db() + assert cancelled_event.invoiced is False + + +@pytest.mark.freeze_time('2022-05-30 14:00') +def test_events_invoiced_agendas_filter(app, user): + agenda1 = Agenda.objects.create(label='Foo') + agenda2 = Agenda.objects.create(label='Foo 2') + event1 = Event.objects.create( + slug='event-1', + label='Event 1', + start_datetime=now(), + places=10, + agenda=agenda1, + ) + event2 = Event.objects.create( + slug='event-2', + label='Event 2', + start_datetime=now(), + places=10, + agenda=agenda2, + ) + + app.authorization = ('Basic', ('john.doe', 'password')) + url = '/api/agendas/events/invoiced/' + params = { + 'invoiced': True, + 'agendas': 'foo, foo-2', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + } + app.post_json(url, params=params) + event1.refresh_from_db() + assert event1.invoiced is True + event2.refresh_from_db() + assert event2.invoiced is True + + params['agendas'] = 'foo' + params['invoiced'] = False + app.post_json(url, params=params) + event1.refresh_from_db() + assert event1.invoiced is False + event2.refresh_from_db() + assert event2.invoiced is True + + params['agendas'] = 'foo-2' + app.post_json(url, params=params) + event1.refresh_from_db() + assert event1.invoiced is False + event2.refresh_from_db() + assert event2.invoiced is False + + +@pytest.mark.parametrize( + 'event_date, expected', + [ + # just before first day + ((2022, 4, 30, 12, 0), False), + # first day + ((2022, 5, 1, 12, 0), True), + # last day + ((2022, 5, 31, 12, 0), True), + # just after last day + ((2022, 6, 1, 12, 0), False), + ], +) +def test_events_invoiced_date_filter(app, user, event_date, expected): + agenda = Agenda.objects.create(label='Foo') + event = Event.objects.create( + slug='event', + label='Event', + start_datetime=make_aware(datetime.datetime(*event_date)), + places=10, + agenda=agenda, + ) + + app.authorization = ('Basic', ('john.doe', 'password')) + url = '/api/agendas/events/invoiced/' + params = { + 'invoiced': True, + 'agendas': 'foo', + 'date_start': '2022-05-01', + 'date_end': '2022-06-01', + } + app.post_json(url, params=params) + event.refresh_from_db() + assert event.invoiced == expected -- 2.39.2 From ba91b0d52005474671a81a4f131457322c89d6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= Date: Fri, 17 Mar 2023 15:58:47 +0100 Subject: [PATCH 2/2] manager: display invoiced tag (#75415) --- .../chrono/manager_agenda_event_fragment.html | 4 +++- .../templates/chrono/manager_event_detail.html | 6 +++++- tests/manager/test_event.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html index 536f91b1..39dd2a7c 100644 --- a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html +++ b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html @@ -49,7 +49,9 @@ {% if event.main_list_full %} {% trans "Full" %} {% endif %} - {% if event.check_locked %} + {% if event.invoiced %} + {% trans "Invoiced" %} + {% elif event.check_locked %} {% trans "Check locked" %} {% endif %} {% if event.checked %} diff --git a/chrono/manager/templates/chrono/manager_event_detail.html b/chrono/manager/templates/chrono/manager_event_detail.html index 0feef473..efdad16c 100644 --- a/chrono/manager/templates/chrono/manager_event_detail.html +++ b/chrono/manager/templates/chrono/manager_event_detail.html @@ -27,7 +27,11 @@
{% if event.cancellation_status %}{{ event.cancellation_status }}{% endif %} {% if event.main_list_full %}{% trans "Full" %}{% endif %} - {% if event.check_locked %}{% trans "Check locked" %}{% endif %} + {% if event.invoiced %} + {% trans "Invoiced" %} + {% elif event.check_locked %} + {% trans "Check locked" %} + {% endif %} {% if event.checked %}{% trans "Checked" %}{% endif %} {% if event.is_day_past and not event.cancelled %} {% if event.present_count %}{% blocktrans with count=event.present_count %}Presents {{ count }}{% endblocktrans %}{% endif %} diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py index 998d1e6a..cbd9041f 100644 --- a/tests/manager/test_event.py +++ b/tests/manager/test_event.py @@ -1717,6 +1717,7 @@ def test_event_checked(app, admin_user): resp = app.get(url) assert 'Checked' not in resp assert 'check-locked' not in resp + assert 'invoiced' not in resp assert 'Presents 3' in resp assert 'Absents 4' in resp assert 'Not checked 1' in resp @@ -1738,6 +1739,7 @@ def test_event_checked(app, admin_user): resp = app.get(url) assert 'Checked' in resp assert 'check-locked' not in resp + assert 'invoiced' not in resp assert 'Presents 4' in resp assert 'Absents 4' in resp assert 'meta meta-disabled' not in resp @@ -1780,12 +1782,21 @@ def test_event_checked(app, admin_user): for url in urls: resp = app.get(url) assert 'Check locked' in resp + assert 'invoiced' not in resp app.post( '/manage/agendas/%s/events/%s/checked' % (agenda.pk, event.pk), params={'csrfmiddlewaretoken': token}, status=404, ) + # event check is locked and envent is invoiced + event.invoiced = True + event.save() + for url in urls: + resp = app.get(url) + assert 'check-locked' not in resp + assert 'Invoiced' in resp + @mock.patch('chrono.manager.forms.get_agenda_check_types') def test_event_check_filters(check_types, app, admin_user): -- 2.39.2