0002-api-allow-booking-all-recurrences-of-recurring-event.patch
chrono/agendas/models.py | ||
---|---|---|
664 | 664 | |
665 | 665 |
return entries |
666 | 666 | |
667 |
def get_open_recurring_events(self): |
|
668 |
return self.event_set.filter( |
|
669 |
Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()), |
|
670 |
recurrence_days__isnull=False, |
|
671 |
recurrence_end_date__gt=localtime(now()).date(), |
|
672 |
) |
|
673 | ||
667 | 674 |
def add_event_recurrences( |
668 | 675 |
self, |
669 | 676 |
events, |
chrono/api/urls.py | ||
---|---|---|
22 | 22 |
url(r'^agenda/$', views.agendas), |
23 | 23 |
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/$', views.agenda_detail), |
24 | 24 |
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'), |
25 |
url( |
|
26 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_events/$', |
|
27 |
views.recurring_events_list, |
|
28 |
name='api-agenda-recurring-events', |
|
29 |
), |
|
25 | 30 |
url( |
26 | 31 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslot/(?P<event_identifier>[\w:-]+)/$', |
27 | 32 |
views.fillslot, |
28 | 33 |
name='api-fillslot', |
29 | 34 |
), |
30 | 35 |
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'), |
36 |
url( |
|
37 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_fillslots/$', |
|
38 |
views.recurring_fillslots, |
|
39 |
name='api-recurring-fillslots', |
|
40 |
), |
|
31 | 41 |
url( |
32 | 42 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$', |
33 | 43 |
views.slot_status, |
chrono/api/views.py | ||
---|---|---|
19 | 19 |
import itertools |
20 | 20 |
import uuid |
21 | 21 | |
22 |
import django |
|
23 |
from django.conf import settings |
|
22 | 24 |
from django.db import transaction |
23 |
from django.db.models import Count, Prefetch, Q
|
|
25 |
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Max, Prefetch, Q, Value
|
|
24 | 26 |
from django.db.models.functions import TruncDay |
25 | 27 |
from django.http import Http404, HttpResponse |
26 | 28 |
from django.shortcuts import get_object_or_404 |
27 | 29 |
from django.template import Context, Template |
28 | 30 |
from django.urls import reverse |
29 | 31 |
from django.utils.dateparse import parse_date, parse_datetime |
32 |
from django.utils.dates import WEEKDAYS |
|
30 | 33 |
from django.utils.encoding import force_text |
31 | 34 |
from django.utils.formats import date_format |
32 | 35 |
from django.utils.timezone import is_naive, localtime, make_aware, now |
... | ... | |
413 | 416 |
return False |
414 | 417 | |
415 | 418 | |
416 |
def get_event_detail(request, event, agenda=None, min_places=1): |
|
417 |
agenda = agenda or event.agenda |
|
419 |
def get_event_text(event, agenda, day=None): |
|
418 | 420 |
event_text = force_text(event) |
419 | 421 |
if agenda.event_display_template: |
420 | 422 |
event_text = Template(agenda.event_display_template).render(Context({'event': event})) |
... | ... | |
423 | 425 |
event.label, |
424 | 426 |
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'), |
425 | 427 |
) |
428 |
elif event.recurrence_days: |
|
429 |
event_text = _('%s: %s') % (WEEKDAYS[day].capitalize(), event_text) |
|
430 |
return event_text |
|
431 | ||
432 | ||
433 |
def get_event_detail(request, event, agenda=None, min_places=1): |
|
434 |
agenda = agenda or event.agenda |
|
426 | 435 |
return { |
427 | 436 |
'id': event.slug, |
428 | 437 |
'slug': event.slug, # kept for compatibility |
429 |
'text': event_text,
|
|
438 |
'text': get_event_text(event, agenda),
|
|
430 | 439 |
'datetime': format_response_datetime(event.start_datetime), |
431 | 440 |
'description': event.description, |
432 | 441 |
'pricing': event.pricing, |
... | ... | |
556 | 565 |
return start_datetime, end_datetime |
557 | 566 | |
558 | 567 | |
568 |
def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None): |
|
569 |
return Booking( |
|
570 |
event_id=event.pk, |
|
571 |
in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list), |
|
572 |
primary_booking=primary_booking, |
|
573 |
label=payload.get('label', ''), |
|
574 |
user_external_id=payload.get('user_external_id', ''), |
|
575 |
user_first_name=payload.get('user_first_name', ''), |
|
576 |
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '', |
|
577 |
user_email=payload.get('user_email', ''), |
|
578 |
user_phone_number=payload.get('user_phone_number', ''), |
|
579 |
form_url=payload.get('form_url', ''), |
|
580 |
backoffice_url=payload.get('backoffice_url', ''), |
|
581 |
cancel_callback_url=payload.get('cancel_callback_url', ''), |
|
582 |
user_display_label=payload.get('user_display_label', ''), |
|
583 |
extra_data=extra_data, |
|
584 |
color=color, |
|
585 |
) |
|
586 | ||
587 | ||
559 | 588 |
class Agendas(APIView): |
560 | 589 |
permission_classes = () |
561 | 590 | |
... | ... | |
814 | 843 |
meeting_datetimes = MeetingDatetimes.as_view() |
815 | 844 | |
816 | 845 | |
846 |
class RecurringEventsList(APIView): |
|
847 |
permission_classes = () |
|
848 | ||
849 |
def get(self, request, agenda_identifier=None, format=None): |
|
850 |
if not settings.ENABLE_RECURRING_EVENT_BOOKING: |
|
851 |
raise Http404() |
|
852 | ||
853 |
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') |
|
854 |
entries = agenda.get_open_recurring_events() |
|
855 | ||
856 |
events = [] |
|
857 |
for event in entries: |
|
858 |
for day in event.recurrence_days: |
|
859 |
slug = '%s:%s' % (event.slug, day) |
|
860 |
events.append( |
|
861 |
{ |
|
862 |
'id': slug, |
|
863 |
'text': get_event_text(event, agenda, day), |
|
864 |
'datetime': format_response_datetime(event.start_datetime), |
|
865 |
'description': event.description, |
|
866 |
'pricing': event.pricing, |
|
867 |
'url': event.url, |
|
868 |
} |
|
869 |
) |
|
870 | ||
871 |
return Response({'data': events}) |
|
872 | ||
873 | ||
874 |
recurring_events_list = RecurringEventsList.as_view() |
|
875 | ||
876 | ||
817 | 877 |
class MeetingList(APIView): |
818 | 878 |
permission_classes = () |
819 | 879 | |
... | ... | |
1282 | 1342 |
primary_booking = None |
1283 | 1343 |
for event in events: |
1284 | 1344 |
for i in range(places_count): |
1285 |
new_booking = Booking( |
|
1286 |
event_id=event.id, |
|
1287 |
in_waiting_list=in_waiting_list, |
|
1288 |
label=payload.get('label', ''), |
|
1289 |
user_external_id=payload.get('user_external_id', ''), |
|
1290 |
user_first_name=payload.get('user_first_name', ''), |
|
1291 |
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '', |
|
1292 |
user_email=payload.get('user_email', ''), |
|
1293 |
user_phone_number=payload.get('user_phone_number', ''), |
|
1294 |
form_url=payload.get('form_url', ''), |
|
1295 |
backoffice_url=payload.get('backoffice_url', ''), |
|
1296 |
cancel_callback_url=payload.get('cancel_callback_url', ''), |
|
1297 |
user_display_label=payload.get('user_display_label', ''), |
|
1298 |
extra_data=extra_data, |
|
1299 |
color=color, |
|
1345 |
new_booking = make_booking( |
|
1346 |
event, payload, extra_data, primary_booking, in_waiting_list, color |
|
1300 | 1347 |
) |
1301 |
if primary_booking is not None: |
|
1302 |
new_booking.primary_booking = primary_booking |
|
1303 | 1348 |
new_booking.save() |
1304 | 1349 |
if primary_booking is None: |
1305 | 1350 |
primary_booking = new_booking |
... | ... | |
1386 | 1431 |
fillslot = Fillslot.as_view() |
1387 | 1432 | |
1388 | 1433 | |
1434 |
class RecurringFillslots(APIView): |
|
1435 |
permission_classes = (permissions.IsAuthenticated,) |
|
1436 |
serializer_class = SlotsSerializer |
|
1437 | ||
1438 |
def post(self, request, agenda_identifier=None, format=None): |
|
1439 |
if not settings.ENABLE_RECURRING_EVENT_BOOKING: |
|
1440 |
raise Http404() |
|
1441 | ||
1442 |
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') |
|
1443 |
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request) |
|
1444 |
if not start_datetime or start_datetime < now(): |
|
1445 |
start_datetime = now() |
|
1446 | ||
1447 |
serializer = self.serializer_class(data=request.data, partial=True) |
|
1448 |
if not serializer.is_valid(): |
|
1449 |
raise APIError( |
|
1450 |
_('invalid payload'), |
|
1451 |
err_class='invalid payload', |
|
1452 |
errors=serializer.errors, |
|
1453 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
1454 |
) |
|
1455 |
payload = serializer.validated_data |
|
1456 | ||
1457 |
user_external_id = payload.get('user_external_id') |
|
1458 |
if not user_external_id: |
|
1459 |
raise APIError( |
|
1460 |
_('user_external_id is required'), |
|
1461 |
err_class='user_external_id is required', |
|
1462 |
errors=serializer.errors, |
|
1463 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
1464 |
) |
|
1465 | ||
1466 |
open_event_slugs = set(agenda.get_open_recurring_events().values_list('slug', flat=True)) |
|
1467 |
slots = collections.defaultdict(list) |
|
1468 |
for slot in payload['slots']: |
|
1469 |
try: |
|
1470 |
slug, day = slot.split(':') |
|
1471 |
day = int(day) |
|
1472 |
except ValueError: |
|
1473 |
raise APIError( |
|
1474 |
_('invalid slot: %s') % slot, |
|
1475 |
err_class='invalid slot: %s' % slot, |
|
1476 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
1477 |
) |
|
1478 |
if slug not in open_event_slugs: |
|
1479 |
raise APIError( |
|
1480 |
_('event %s is not bookable') % slug, |
|
1481 |
err_class='event %s is not bookable' % slug, |
|
1482 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
1483 |
) |
|
1484 |
# convert ISO day number to db lookup day number |
|
1485 |
day = (day + 1) % 7 + 1 |
|
1486 |
slots[slug].append(day) |
|
1487 | ||
1488 |
event_filter = Q() |
|
1489 |
for slug, days in slots.items(): |
|
1490 |
event_filter |= Q(agenda=agenda, primary_event__slug=slug, start_datetime__week_day__in=days) |
|
1491 | ||
1492 |
events_to_book = Event.objects.filter(event_filter) |
|
1493 |
events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False) |
|
1494 | ||
1495 |
full_events = list(events_to_book.filter(full=True)) |
|
1496 |
events_to_book = events_to_book.filter(full=False) |
|
1497 |
if end_datetime: |
|
1498 |
events_to_book = events_to_book.filter(start_datetime__lte=end_datetime) |
|
1499 |
if not events_to_book.exists(): |
|
1500 |
if full_events: |
|
1501 |
raise APIError(_('all events are all full'), err_class='all events are all full') |
|
1502 |
else: |
|
1503 |
raise APIError(_('no event recurrences to book'), err_class='no event recurrences to book') |
|
1504 | ||
1505 |
events_to_book = Event.annotate_queryset(events_to_book) |
|
1506 |
events_to_book = events_to_book.annotate( |
|
1507 |
in_waiting_list=ExpressionWrapper( |
|
1508 |
Q(booked_places_count__gte=F('places')), output_field=BooleanField() |
|
1509 |
) |
|
1510 |
) |
|
1511 | ||
1512 |
extra_data = {k: v for k, v in request.data.items() if k not in payload} |
|
1513 |
bookings = [make_booking(event, payload, extra_data) for event in events_to_book] |
|
1514 | ||
1515 |
with transaction.atomic(): |
|
1516 |
Booking.objects.bulk_create(bookings) |
|
1517 |
if django.VERSION < (2, 0): |
|
1518 |
from django.db.models import Case, When |
|
1519 | ||
1520 |
events_to_book.update( |
|
1521 |
full=Case( |
|
1522 |
When( |
|
1523 |
Q(booked_places_count__gte=F('places'), waiting_list_places=0) |
|
1524 |
| Q( |
|
1525 |
waiting_list_places__gt=0, |
|
1526 |
waiting_list_count__gte=F('waiting_list_places'), |
|
1527 |
), |
|
1528 |
then=Value(True), |
|
1529 |
), |
|
1530 |
default=Value(False), |
|
1531 |
), |
|
1532 |
almost_full=Case( |
|
1533 |
When(Q(booked_places_count__gte=0.9 * F('places')), then=Value(True)), |
|
1534 |
default=Value(False), |
|
1535 |
), |
|
1536 |
) |
|
1537 |
else: |
|
1538 |
events_to_book.update( |
|
1539 |
full=Q(booked_places_count__gte=F('places'), waiting_list_places=0) |
|
1540 |
| Q(waiting_list_places__gt=0, waiting_list_count__gte=F('waiting_list_places')), |
|
1541 |
almost_full=Q(booked_places_count__gte=0.9 * F('places')), |
|
1542 |
) |
|
1543 | ||
1544 |
response = { |
|
1545 |
'err': 0, |
|
1546 |
'booking_count': len(bookings), |
|
1547 |
'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events], |
|
1548 |
} |
|
1549 |
return Response(response) |
|
1550 | ||
1551 | ||
1552 |
recurring_fillslots = RecurringFillslots.as_view() |
|
1553 | ||
1554 | ||
1389 | 1555 |
class BookingSerializer(serializers.ModelSerializer): |
1390 | 1556 |
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True) |
1391 | 1557 |
chrono/settings.py | ||
---|---|---|
188 | 188 | |
189 | 189 |
REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'} |
190 | 190 | |
191 |
ENABLE_RECURRING_EVENT_BOOKING = False |
|
192 | ||
191 | 193 |
local_settings_file = os.environ.get( |
192 | 194 |
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py') |
193 | 195 |
) |
tests/api/test_datetimes.py | ||
---|---|---|
648 | 648 |
app.authorization = ('Basic', ('john.doe', 'password')) |
649 | 649 |
resp = app.post(fillslot_url, status=400) |
650 | 650 |
assert resp.json['err'] == 1 |
651 | ||
652 | ||
653 |
def test_recurring_events_api_list(app, freezer): |
|
654 |
freezer.move_to('2021-09-06 12:00') |
|
655 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
656 |
Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda) |
|
657 |
event = Event.objects.create( |
|
658 |
label='Example Event', |
|
659 |
start_datetime=now(), |
|
660 |
recurrence_days=[0, 3, 4], # Monday, Thursday, Friday |
|
661 |
places=2, |
|
662 |
agenda=agenda, |
|
663 |
) |
|
664 | ||
665 |
resp = app.get('/api/agenda/xxx/recurring_events/', status=404) |
|
666 | ||
667 |
# recurring events without recurrence_end_date are not bookable |
|
668 |
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) |
|
669 |
assert len(resp.json['data']) == 0 |
|
670 | ||
671 |
event.recurrence_end_date = now() + datetime.timedelta(days=30) |
|
672 |
event.save() |
|
673 |
start_datetime = now() + datetime.timedelta(days=15) |
|
674 |
Event.objects.create( |
|
675 |
label='Other', |
|
676 |
start_datetime=start_datetime, |
|
677 |
recurrence_days=[start_datetime.weekday()], |
|
678 |
places=2, |
|
679 |
agenda=agenda, |
|
680 |
recurrence_end_date=now() + datetime.timedelta(days=45), |
|
681 |
) |
|
682 | ||
683 |
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) |
|
684 |
assert len(resp.json['data']) == 4 |
|
685 |
assert resp.json['data'][0]['id'] == 'example-event:0' |
|
686 |
assert resp.json['data'][0]['text'] == 'Monday: Example Event' |
|
687 |
assert resp.json['data'][1]['id'] == 'example-event:3' |
|
688 |
assert resp.json['data'][1]['text'] == 'Thursday: Example Event' |
|
689 |
assert resp.json['data'][2]['id'] == 'example-event:4' |
|
690 |
assert resp.json['data'][2]['text'] == 'Friday: Example Event' |
|
691 |
assert resp.json['data'][3]['id'] == 'other:1' |
|
692 |
assert resp.json['data'][3]['text'] == 'Tuesday: Other' |
|
693 | ||
694 |
event.publication_date = now() + datetime.timedelta(days=2) |
|
695 |
event.save() |
|
696 |
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) |
|
697 |
assert len(resp.json['data']) == 1 |
|
698 | ||
699 |
freezer.move_to(event.recurrence_end_date) |
|
700 |
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) |
|
701 |
assert len(resp.json['data']) == 1 |
tests/api/test_fillslot.py | ||
---|---|---|
1891 | 1891 |
ics = app.get(resp.json['api']['ics_url']).text |
1892 | 1892 |
assert 'DTSTART:20170519T231200Z' in ics |
1893 | 1893 |
assert 'DTEND:20170520T004200Z' in ics |
1894 | ||
1895 | ||
1896 |
def test_recurring_events_api_fillslots(app, user, freezer): |
|
1897 |
freezer.move_to('2021-09-06 12:00') |
|
1898 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
1899 |
event = Event.objects.create( |
|
1900 |
label='Event', |
|
1901 |
start_datetime=now(), |
|
1902 |
recurrence_days=[0, 1, 3, 4], # Monday, Tuesday, Thursday, Friday |
|
1903 |
places=2, |
|
1904 |
waiting_list_places=1, |
|
1905 |
agenda=agenda, |
|
1906 |
recurrence_end_date=now() + datetime.timedelta(days=364), |
|
1907 |
) |
|
1908 |
event.create_all_recurrences() |
|
1909 |
sunday_event = Event.objects.create( |
|
1910 |
label='Sunday Event', |
|
1911 |
start_datetime=now(), |
|
1912 |
recurrence_days=[6], |
|
1913 |
places=2, |
|
1914 |
waiting_list_places=1, |
|
1915 |
agenda=agenda, |
|
1916 |
recurrence_end_date=now() + datetime.timedelta(days=364), |
|
1917 |
) |
|
1918 |
sunday_event.create_all_recurrences() |
|
1919 | ||
1920 |
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) |
|
1921 |
assert len(resp.json['data']) == 5 |
|
1922 | ||
1923 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
1924 |
fillslots_url = '/api/agenda/%s/recurring_fillslots/' % agenda.slug |
|
1925 |
params = {'user_external_id': 'user_id'} |
|
1926 |
# Book Monday and Thursday of first event and Sunday of second event |
|
1927 |
params['slots'] = 'event:0,event:3,sunday-event:6' |
|
1928 |
resp = app.post_json(fillslots_url, params=params) |
|
1929 |
assert resp.json['booking_count'] == 156 |
|
1930 | ||
1931 |
assert Booking.objects.count() == 156 |
|
1932 |
assert Booking.objects.filter(event__primary_event=event).count() == 104 |
|
1933 |
assert Booking.objects.filter(event__primary_event=sunday_event).count() == 52 |
|
1934 | ||
1935 |
events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False)) |
|
1936 |
assert events.filter(booked_places_count=1).count() == 156 |
|
1937 | ||
1938 |
# one recurrence is booked separately |
|
1939 |
event = Event.objects.filter(primary_event__isnull=False).first() |
|
1940 |
Booking.objects.create(event=event) |
|
1941 | ||
1942 |
params['user_external_id'] = 'user_id_2' |
|
1943 |
resp = app.post_json(fillslots_url, params=params) |
|
1944 |
assert resp.json['booking_count'] == 156 |
|
1945 |
assert not resp.json['full_events'] |
|
1946 |
assert Booking.objects.count() == 313 |
|
1947 |
events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False)) |
|
1948 |
assert events.filter(booked_places_count=2).count() == 156 |
|
1949 |
# one booking has been put in waiting list |
|
1950 |
assert events.filter(waiting_list_count=1).count() == 1 |
|
1951 | ||
1952 |
params['user_external_id'] = 'user_id_3' |
|
1953 |
resp = app.post_json(fillslots_url, params=params) |
|
1954 |
# everything goes in waiting list |
|
1955 |
assert events.filter(waiting_list_count=1).count() == 156 |
|
1956 |
# but an event was full |
|
1957 |
assert resp.json['booking_count'] == 155 |
|
1958 |
assert len(resp.json['full_events']) == 1 |
|
1959 |
assert resp.json['full_events'][0]['slug'] == event.slug |
|
1960 | ||
1961 |
params['user_external_id'] = 'user_id_4' |
|
1962 |
resp = app.post_json(fillslots_url, params=params) |
|
1963 |
assert resp.json['err'] == 1 |
|
1964 |
assert resp.json['err_desc'] == 'all events are all full' |
|
1965 | ||
1966 |
params['slots'] = 'event:1' |
|
1967 |
resp = app.post_json(fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', params=params) |
|
1968 |
assert resp.json['booking_count'] == 4 |
|
1969 |
assert Booking.objects.filter(user_external_id='user_id_4').count() == 4 |
|
1970 | ||
1971 |
resp = app.post_json(fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params=params) |
|
1972 |
assert resp.json['err'] == 1 |
|
1973 |
assert resp.json['err_desc'] == 'no event recurrences to book' |
|
1974 | ||
1975 |
del params['user_external_id'] |
|
1976 |
resp = app.post_json(fillslots_url, params=params, status=400) |
|
1977 |
assert resp.json['err'] == 1 |
|
1978 |
assert resp.json['err_desc'] == 'user_external_id is required' |
|
1979 | ||
1980 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:a'}, status=400) |
|
1981 |
assert resp.json['err'] == 1 |
|
1982 |
assert resp.json['err_desc'] == 'invalid slot: a:a' |
|
1983 | ||
1984 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:1'}, status=400) |
|
1985 |
assert resp.json['err'] == 1 |
|
1986 |
assert resp.json['err_desc'] == 'event a is not bookable' |
tests/settings.py | ||
---|---|---|
32 | 32 |
EXCEPTIONS_SOURCES = {} |
33 | 33 | |
34 | 34 |
SITE_BASE_URL = 'https://example.com' |
35 | ||
36 |
ENABLE_RECURRING_EVENT_BOOKING = True |
|
35 |
- |