Projet

Général

Profil

0001-api-filter-by-subscriptions-in-multiple-agendas-date.patch

Valentin Deniaud, 02 décembre 2021 15:28

Télécharger (15,6 ko)

Voir les différences:

Subject: [PATCH 1/4] api: filter by subscriptions in multiple agendas
 datetimes (#58446)

 chrono/agendas/models.py    |  15 +++-
 chrono/api/serializers.py   |  45 +++++++++++-
 chrono/api/views.py         |  14 ++--
 tests/api/test_datetimes.py | 138 +++++++++++++++++++++++++++++++++++-
 tests/api/test_fillslot.py  |   2 +-
 tests/test_api_utils.py     |   8 ++-
 6 files changed, 207 insertions(+), 15 deletions(-)
chrono/agendas/models.py
33 33
from django.core.exceptions import FieldDoesNotExist, ValidationError
34 34
from django.core.validators import MaxValueValidator, MinValueValidator
35 35
from django.db import IntegrityError, connection, models, transaction
36
from django.db.models import Count, Max, Prefetch, Q
36
from django.db.models import Count, F, Max, Prefetch, Q
37 37
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
38 38
from django.urls import reverse
39 39
from django.utils import functional
......
898 898

  
899 899
    @staticmethod
900 900
    def prefetch_events_and_exceptions(
901
        qs, user_external_id=None, show_past_events=False, min_start=None, max_start=None
901
        qs,
902
        user_external_id=None,
903
        show_past_events=False,
904
        show_only_subscribed=False,
905
        min_start=None,
906
        max_start=None,
902 907
    ):
903 908
        event_queryset = Event.objects.filter(
904 909
            Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()),
......
914 919
            event_queryset = event_queryset.filter(start_datetime__gte=min_start)
915 920
        if max_start:
916 921
            event_queryset = event_queryset.filter(start_datetime__lt=max_start)
922
        if show_only_subscribed:
923
            event_queryset = event_queryset.filter(
924
                agenda__subscriptions__user_external_id=user_external_id,
925
                agenda__subscriptions__date_start__lt=F('start_datetime'),
926
                agenda__subscriptions__date_end__gt=F('start_datetime'),
927
            )
917 928

  
918 929
        exceptions_desk = Desk.objects.filter(slug='_exceptions_holder').prefetch_related(
919 930
            'unavailability_calendars'
chrono/api/serializers.py
8 8
from chrono.agendas.models import AbsenceReason, Agenda, Booking, Category, Event, Subscription
9 9

  
10 10

  
11
def get_objects_from_slugs(slugs, qs):
12
    slugs = set(slugs)
13
    objects = qs.filter(slug__in=slugs)
14
    if len(objects) != len(slugs):
15
        unknown_slugs = sorted(slugs - {obj.slug for obj in objects})
16
        unknown_slugs = ', '.join(unknown_slugs)
17
        raise ValidationError(('invalid slugs: %s') % unknown_slugs)
18
    return objects
19

  
20

  
11 21
class StringOrListField(serializers.ListField):
12 22
    def to_internal_value(self, data):
13 23
        if isinstance(data, str):
......
190 200
        return attrs
191 201

  
192 202

  
193
class MultipleAgendasDatetimesSerializer(DatetimesSerializer):
203
class AgendaOrSubscribedSlugsMixin(metaclass=serializers.SerializerMetaclass):
194 204
    agendas = CommaSeparatedStringField(
195
        required=True, child=serializers.SlugField(max_length=160, allow_blank=False)
205
        required=False, child=serializers.SlugField(max_length=160, allow_blank=False)
206
    )
207
    subscribed = CommaSeparatedStringField(
208
        required=False, child=serializers.SlugField(max_length=160, allow_blank=False)
196 209
    )
210

  
211
    def validate(self, attrs):
212
        super().validate(attrs)
213
        if 'agendas' not in attrs and 'subscribed' not in attrs:
214
            raise ValidationError(_('Either "agendas" or "subscribed" parameter is required.'))
215
        if 'agendas' in attrs and 'subscribed' in attrs:
216
            raise ValidationError(_('"agendas" and "subscribed" parameters are mutually exclusive.'))
217
        user_external_id = attrs.get('user_external_id')
218
        if 'subscribed' in attrs and not user_external_id:
219
            raise ValidationError(
220
                {'user_external_id': _('This field is required when using "subscribed" parameter.')}
221
            )
222

  
223
        if 'subscribed' in attrs:
224
            agendas = Agenda.objects.filter(subscriptions__user_external_id=user_external_id).distinct()
225
            if attrs['subscribed'] != ['all']:
226
                agendas = agendas.filter(category__slug__in=attrs['subscribed'])
227
            attrs['agendas'] = agendas
228
        else:
229
            attrs['agenda_slugs'] = self.agenda_slugs
230
        return attrs
231

  
232
    def validate_agendas(self, value):
233
        self.agenda_slugs = value
234
        return get_objects_from_slugs(value, qs=Agenda.objects.filter(kind='events'))
235

  
236

  
237
class MultipleAgendasDatetimesSerializer(AgendaOrSubscribedSlugsMixin, DatetimesSerializer):
197 238
    show_past_events = serializers.BooleanField(default=False)
198 239

  
199 240

  
chrono/api/views.py
866 866
            raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
867 867
        payload = serializer.validated_data
868 868

  
869
        agenda_slugs = payload['agendas']
870
        agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events'))
871

  
869
        agendas = payload['agendas']
872 870
        user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
873 871
        disable_booked = bool(payload.get('exclude_user_external_id'))
874 872
        show_past_events = bool(payload.get('show_past_events'))
......
876 874
            agendas,
877 875
            user_external_id=user_external_id,
878 876
            show_past_events=show_past_events,
877
            show_only_subscribed=bool('subscribed' in payload),
879 878
            min_start=payload.get('date_start'),
880 879
            max_start=payload.get('date_end'),
881 880
        )
......
900 899
                )
901 900
            )
902 901

  
903
        agenda_querystring_indexes = {agenda_slug: i for i, agenda_slug in enumerate(agenda_slugs)}
904
        entries.sort(key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.slug]))
902
        if 'agendas' in request.query_params:
903
            agenda_querystring_indexes = {
904
                agenda_slug: i for i, agenda_slug in enumerate(payload['agenda_slugs'])
905
            }
906
            entries.sort(
907
                key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.slug])
908
            )
905 909

  
906 910
        response = {
907 911
            'data': [
tests/api/test_datetimes.py
7 7
from django.test.utils import CaptureQueriesContext
8 8
from django.utils.timezone import localtime, make_aware, make_naive, now
9 9

  
10
from chrono.agendas.models import Agenda, Booking, Desk, Event, TimePeriodException
10
from chrono.agendas.models import Agenda, Booking, Category, Desk, Event, Subscription, TimePeriodException
11 11

  
12 12
pytestmark = pytest.mark.django_db
13 13

  
......
1584 1584

  
1585 1585
    # invalid slugs
1586 1586
    resp = app.get('/api/agendas/datetimes/', params={'agendas': 'xxx'}, status=400)
1587
    assert resp.json['err_desc'] == 'invalid slugs: xxx'
1587
    assert resp.json['errors']['agendas'][0] == 'invalid slugs: xxx'
1588 1588

  
1589 1589
    resp = app.get('/api/agendas/datetimes/', params={'agendas': 'first-agenda,xxx,yyy'}, status=400)
1590
    assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy'
1590
    assert resp.json['errors']['agendas'][0] == 'invalid slugs: xxx, yyy'
1591

  
1592
    # missing agendas parameter
1593
    resp = app.get('/api/agendas/datetimes/', params={}, status=400)
1594
    assert resp.json['err_desc'] == 'invalid payload'
1591 1595

  
1592 1596
    # it's possible to show past events
1593 1597
    resp = app.get('/api/agendas/datetimes/', params={'agendas': agenda_slugs, 'show_past_events': True})
......
1753 1757
def test_datetimes_multiple_agendas_queries(app):
1754 1758
    for i in range(10):
1755 1759
        agenda = Agenda.objects.create(label=str(i), kind='events')
1760
        Subscription.objects.create(
1761
            agenda=agenda,
1762
            user_external_id='xxx',
1763
            date_start=now() - datetime.timedelta(days=10),
1764
            date_end=now() + datetime.timedelta(days=10),
1765
        )
1756 1766
        Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
1757 1767
        Event.objects.create(start_datetime=now() - datetime.timedelta(days=5), places=5, agenda=agenda)
1758 1768
        Event.objects.create(start_datetime=now() + datetime.timedelta(days=5), places=5, agenda=agenda)
......
1765 1775
        )
1766 1776
        assert len(resp.json['data']) == 30
1767 1777
        assert len(ctx.captured_queries) == 7
1778

  
1779
    with CaptureQueriesContext(connection) as ctx:
1780
        resp = app.get(
1781
            '/api/agendas/datetimes/',
1782
            params={'subscribed': 'all', 'user_external_id': 'xxx', 'show_past_events': True},
1783
        )
1784
        assert len(resp.json['data']) == 30
1785
        assert len(ctx.captured_queries) == 7
1786

  
1787

  
1788
@pytest.mark.freeze_time('2021-05-06 14:00')
1789
def test_datetimes_multiple_agendas_subscribed(app):
1790
    first_agenda = Agenda.objects.create(label='First agenda', kind='events')
1791
    Desk.objects.create(agenda=first_agenda, slug='_exceptions_holder')
1792
    Event.objects.create(
1793
        slug='event',
1794
        start_datetime=now() + datetime.timedelta(days=5),
1795
        places=5,
1796
        agenda=first_agenda,
1797
    )
1798
    Event.objects.create(
1799
        slug='event-2',
1800
        start_datetime=now() + datetime.timedelta(days=20),
1801
        places=5,
1802
        agenda=first_agenda,
1803
    )
1804
    category = Category.objects.create(label='Category A')
1805
    second_agenda = Agenda.objects.create(
1806
        label='Second agenda', kind='events', category=category, maximal_booking_delay=400
1807
    )
1808
    Desk.objects.create(agenda=second_agenda, slug='_exceptions_holder')
1809
    Event.objects.create(
1810
        slug='event',
1811
        start_datetime=now() + datetime.timedelta(days=20),
1812
        places=5,
1813
        agenda=second_agenda,
1814
    )
1815
    Event.objects.create(
1816
        slug='next-year-event',
1817
        start_datetime=now() + datetime.timedelta(days=365),
1818
        places=5,
1819
        agenda=second_agenda,
1820
    )
1821
    Subscription.objects.create(
1822
        agenda=first_agenda,
1823
        user_external_id='yyy',
1824
        date_start=now(),
1825
        date_end=now() + datetime.timedelta(days=10),
1826
    )
1827

  
1828
    # no subscription for user xxx
1829
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'})
1830
    assert len(resp.json['data']) == 0
1831

  
1832
    # add subscription to first agenda
1833
    Subscription.objects.create(
1834
        agenda=first_agenda,
1835
        user_external_id='xxx',
1836
        date_start=now(),
1837
        date_end=now() + datetime.timedelta(days=10),
1838
    )
1839

  
1840
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'})
1841
    assert len(resp.json['data']) == 1
1842
    assert resp.json['data'][0]['id'] == 'first-agenda@event'
1843

  
1844
    # no subscription to second agenda
1845
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'})
1846
    assert len(resp.json['data']) == 0
1847

  
1848
    # add subscription to second agenda
1849
    Subscription.objects.create(
1850
        agenda=second_agenda,
1851
        user_external_id='xxx',
1852
        date_start=now() + datetime.timedelta(days=15),
1853
        date_end=now() + datetime.timedelta(days=25),
1854
    )
1855

  
1856
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'})
1857
    assert len(resp.json['data']) == 1
1858
    assert resp.json['data'][0]['id'] == 'second-agenda@event'
1859

  
1860
    # add new subscription (disjoint) to second agenda
1861
    Subscription.objects.create(
1862
        agenda=second_agenda,
1863
        user_external_id='xxx',
1864
        date_start=now() + datetime.timedelta(days=355),
1865
        date_end=now() + datetime.timedelta(days=375),
1866
    )
1867

  
1868
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'})
1869
    assert len(resp.json['data']) == 2
1870
    assert resp.json['data'][0]['id'] == 'second-agenda@event'
1871
    assert resp.json['data'][1]['id'] == 'second-agenda@next-year-event'
1872

  
1873
    # view events from all subscriptions
1874
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'})
1875
    assert len(resp.json['data']) == 3
1876
    assert resp.json['data'][0]['id'] == 'first-agenda@event'
1877
    assert resp.json['data'][1]['id'] == 'second-agenda@event'
1878
    assert resp.json['data'][2]['id'] == 'second-agenda@next-year-event'
1879

  
1880
    # overlapping subscription changes nothing
1881
    Subscription.objects.create(
1882
        agenda=first_agenda,
1883
        user_external_id='xxx',
1884
        date_start=now() + datetime.timedelta(days=1),
1885
        date_end=now() + datetime.timedelta(days=11),
1886
    )
1887
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'})
1888
    assert len(resp.json['data']) == 3
1889

  
1890
    # check errors
1891
    resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all'}, status=400)
1892
    assert 'required' in resp.json['errors']['user_external_id'][0]
1893

  
1894
    resp = app.get(
1895
        '/api/agendas/datetimes/',
1896
        params={'subscribed': 'all', 'agendas': 'first-agenda', 'user_external_id': 'xxx'},
1897
        status=400,
1898
    )
1899
    assert 'mutually exclusive' in resp.json['errors']['non_field_errors'][0]
tests/api/test_fillslot.py
2664 2664
    resp = app.post_json(
2665 2665
        '/api/agendas/events/fillslots/?agendas=first-agenda,xxx,yyy', params=params, status=400
2666 2666
    )
2667
    assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy'
2667
    assert resp.json['errors']['agendas'][0] == 'invalid slugs: xxx, yyy'
2668 2668

  
2669 2669
    # invalid agenda slugs in payload
2670 2670
    params = {'user_external_id': 'user_id_3', 'slots': 'first-agenda@event,xxx@event,yyy@event'}
tests/test_api_utils.py
1 1
import pytest
2 2

  
3
from chrono.agendas.models import Agenda
3
from chrono.agendas.models import Agenda, MeetingType
4 4
from chrono.api.utils import Response
5 5

  
6 6

  
......
31 31
    assert resp.json['err_desc'] == 'contenu de requête invalide'
32 32
    assert resp.json['err_class'] == 'invalid payload'
33 33

  
34
    resp = app.get('/api/agendas/datetimes/?agendas=hop', status=400)
34
    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
35
    meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo', duration=60)
36
    resp = app.get(
37
        '/api/agenda/%s/meetings/%s/datetimes/?resources=hop' % (agenda.slug, meeting_type.slug), status=400
38
    )
35 39
    assert resp.json['err_desc'] == 'slugs invalides : hop'
36 40
    assert resp.json['err_class'] == 'invalid slugs: hop'
37
-