0001-api-filter-by-subscriptions-in-multiple-agendas-date.patch
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 | ||
229 |
attrs['agenda_slugs'] = [agenda.slug for agenda in agendas] |
|
230 |
return attrs |
|
231 | ||
232 |
def validate_agendas(self, value): |
|
233 |
return get_objects_from_slugs(value, qs=Agenda.objects.filter(kind='events')) |
|
234 | ||
235 | ||
236 |
class MultipleAgendasDatetimesSerializer(AgendaOrSubscribedSlugsMixin, DatetimesSerializer): |
|
197 | 237 |
show_past_events = serializers.BooleanField(default=False) |
198 | 238 | |
199 | 239 |
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 | |
... | ... | |
1589 | 1589 |
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'first-agenda,xxx,yyy'}, status=400) |
1590 | 1590 |
assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy' |
1591 | 1591 | |
1592 |
# missing agendas parameter |
|
1593 |
resp = app.get('/api/agendas/datetimes/', params={}, status=400) |
|
1594 |
assert resp.json['err_desc'] == 'invalid payload' |
|
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}) |
1594 | 1598 |
assert len(resp.json['data']) == 5 |
... | ... | |
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) == 6 |
|
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 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] |
|
1768 |
- |