Compare commits
16 Commits
4ad301264e
...
93b115464b
Author | SHA1 | Date |
---|---|---|
Valentin Deniaud | 93b115464b | |
Valentin Deniaud | 1e434776d7 | |
Valentin Deniaud | b2b885df40 | |
Valentin Deniaud | a65ea62245 | |
Valentin Deniaud | e278707c98 | |
Thomas Jund | 3e478042f6 | |
Benjamin Dauvergne | aff03ffdea | |
Benjamin Dauvergne | 0543594e30 | |
Benjamin Dauvergne | 7fab4c0f41 | |
Benjamin Dauvergne | 5716d6b3dc | |
Benjamin Dauvergne | eafa816253 | |
Benjamin Dauvergne | d6a5861876 | |
Benjamin Dauvergne | 2d8912c0a3 | |
Lauréline Guérin | 3dac9ed0fb | |
Lauréline Guérin | 63a575f303 | |
Lauréline Guérin | 678ac6c1de |
|
@ -0,0 +1,26 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2020 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from chrono.agendas.models import Lease
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean expired leases and related bookings and events'
|
||||
|
||||
def handle(self, **options):
|
||||
Lease.clean()
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0164_alter_bookingcheck_booking'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='previous_state',
|
||||
field=models.CharField(max_length=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='request_uuid',
|
||||
field=models.UUIDField(editable=False, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 3.2.18 on 2023-08-22 08:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
from chrono.agendas.models import get_lease_expiration
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('agendas', '0165_booking_revert'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Lease',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
('lock_code', models.CharField(max_length=64, verbose_name='Lock code')),
|
||||
(
|
||||
'expiration_datetime',
|
||||
models.DateTimeField(verbose_name='Lease expiration time', default=get_lease_expiration),
|
||||
),
|
||||
(
|
||||
'booking',
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='agendas.booking',
|
||||
verbose_name='Booking',
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lease',
|
||||
'verbose_name_plural': 'Leases',
|
||||
},
|
||||
),
|
||||
]
|
|
@ -400,7 +400,7 @@ class Agenda(models.Model):
|
|||
.filter(total=real_agendas.count())
|
||||
)
|
||||
return [
|
||||
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'])
|
||||
MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'], agenda=self)
|
||||
for mt in queryset.order_by('slug')
|
||||
]
|
||||
|
||||
|
@ -1208,6 +1208,7 @@ class Agenda(models.Model):
|
|||
start_datetime=None,
|
||||
end_datetime=None,
|
||||
user_external_id=None,
|
||||
lock_code=None,
|
||||
):
|
||||
"""Get all occupation state of all possible slots for the given agenda (of
|
||||
its real agendas for a virtual agenda) and the given meeting_type.
|
||||
|
@ -1226,6 +1227,7 @@ class Agenda(models.Model):
|
|||
and bookings sets.
|
||||
If it is excluded, ignore it completely.
|
||||
It if is booked, report the slot as full.
|
||||
If it is booked but match the lock code, report the slot as open.
|
||||
"""
|
||||
resources = resources or []
|
||||
# virtual agendas have one constraint :
|
||||
|
@ -1329,6 +1331,8 @@ class Agenda(models.Model):
|
|||
.order_by('desk_id', 'start_datetime', 'meeting_type__duration')
|
||||
.values_list('desk_id', 'start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set by desk from all bookings, using
|
||||
# itertools.groupby() to group them by desk_id
|
||||
bookings.update(
|
||||
|
@ -1362,6 +1366,8 @@ class Agenda(models.Model):
|
|||
.order_by('start_datetime', 'meeting_type__duration')
|
||||
.values_list('start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set
|
||||
resources_bookings = IntervalSet.from_ordered(
|
||||
(event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
|
||||
|
@ -1387,6 +1393,8 @@ class Agenda(models.Model):
|
|||
.order_by('start_datetime', 'meeting_type__duration')
|
||||
.values_list('start_datetime', 'meeting_type__duration')
|
||||
)
|
||||
if lock_code:
|
||||
booked_events = booked_events.exclude(booking__lease__lock_code=lock_code)
|
||||
# compute exclusion set by desk from all bookings, using
|
||||
# itertools.groupby() to group them by desk_id
|
||||
user_bookings = IntervalSet.from_ordered(
|
||||
|
@ -1640,6 +1648,10 @@ class Agenda(models.Model):
|
|||
if to_update:
|
||||
BookingCheck.objects.bulk_update(to_update, ['computed_start_time', 'computed_end_time'])
|
||||
|
||||
def get_datetimes_url(self):
|
||||
assert self.kind == 'events'
|
||||
return reverse('api-agenda-datetimes', kwargs={'agenda_identifier': self.slug})
|
||||
|
||||
|
||||
class VirtualMember(models.Model):
|
||||
"""Trough model to link virtual agendas to their real agendas.
|
||||
|
@ -2031,6 +2043,15 @@ class MeetingType(models.Model):
|
|||
|
||||
return new_meeting_type
|
||||
|
||||
def get_datetimes_url(self):
|
||||
return reverse(
|
||||
'api-agenda-meeting-datetimes',
|
||||
kwargs={
|
||||
'agenda_identifier': self.agenda.slug,
|
||||
'meeting_identifier': self.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
|
@ -2092,6 +2113,12 @@ class Event(models.Model):
|
|||
full_notification_timestamp = models.DateTimeField(null=True, blank=True)
|
||||
cancelled_notification_timestamp = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Store alternate version of booked_places and booked_waiting_list_places,
|
||||
# when an Event queryset is annotated with
|
||||
# Event.annotate_queryset_for_lock_code()
|
||||
unlocked_booked_places = None
|
||||
unlocked_booked_waiting_list_places = None
|
||||
|
||||
class Meta:
|
||||
ordering = ['agenda', 'start_datetime', 'duration', 'label']
|
||||
unique_together = ('agenda', 'slug')
|
||||
|
@ -2321,6 +2348,26 @@ class Event(models.Model):
|
|||
)
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset_for_lock_code(qs, lock_code):
|
||||
qs = qs.annotate(
|
||||
unlocked_booked_places=Count(
|
||||
'booking',
|
||||
filter=Q(
|
||||
booking__cancellation_datetime__isnull=True,
|
||||
)
|
||||
& ~Q(booking__lease__lock_code=lock_code),
|
||||
),
|
||||
unlocked_booked_waiting_list_places=Count(
|
||||
'booking',
|
||||
filter=Q(
|
||||
booking__cancellation_datetime__isnull=False,
|
||||
)
|
||||
& ~Q(booking__lease__lock_code=lock_code),
|
||||
),
|
||||
)
|
||||
return qs
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset_with_overlaps(qs, other_events=None):
|
||||
if not other_events:
|
||||
|
@ -2441,13 +2488,36 @@ class Event(models.Model):
|
|||
notchecked_count=Coalesce(Subquery(notchecked_count, output_field=IntegerField()), Value(0)),
|
||||
)
|
||||
|
||||
def get_booked_places(self):
|
||||
if self.unlocked_booked_places is None:
|
||||
return self.booked_places
|
||||
else:
|
||||
return self.unlocked_booked_places
|
||||
|
||||
def get_booked_waiting_list_places(self):
|
||||
if self.unlocked_booked_waiting_list_places is None:
|
||||
return self.booked_waiting_list_places
|
||||
else:
|
||||
return self.unlocked_booked_waiting_list_places
|
||||
|
||||
def get_full(self):
|
||||
if self.agenda.partial_bookings:
|
||||
return False
|
||||
elif self.unlocked_booked_places is None:
|
||||
return self.full
|
||||
else:
|
||||
if self.waiting_list_places == 0:
|
||||
return self.get_booked_places() >= self.places
|
||||
else:
|
||||
return self.get_booked_waiting_list_places() >= self.waiting_list_places
|
||||
|
||||
@property
|
||||
def remaining_places(self):
|
||||
return max(0, self.places - self.booked_places)
|
||||
return max(0, self.places - self.get_booked_places())
|
||||
|
||||
@property
|
||||
def remaining_waiting_list_places(self):
|
||||
return max(0, self.waiting_list_places - self.booked_waiting_list_places)
|
||||
return max(0, self.waiting_list_places - self.get_booked_waiting_list_places())
|
||||
|
||||
@property
|
||||
def end_datetime(self):
|
||||
|
@ -2848,6 +2918,9 @@ class Booking(models.Model):
|
|||
absence_callback_url = models.URLField(blank=True)
|
||||
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
|
||||
|
||||
request_uuid = models.UUIDField(editable=False, null=True)
|
||||
previous_state = models.CharField(max_length=10, null=True)
|
||||
|
||||
start_time = models.TimeField(null=True)
|
||||
end_time = models.TimeField(null=True)
|
||||
|
||||
|
@ -4607,3 +4680,29 @@ class SharedCustodySettings(models.Model):
|
|||
return cls.objects.get()
|
||||
except cls.DoesNotExist:
|
||||
return cls()
|
||||
|
||||
|
||||
def get_lease_expiration():
|
||||
return now() + datetime.timedelta(seconds=settings.CHRONO_LOCK_DURATION)
|
||||
|
||||
|
||||
class Lease(models.Model):
|
||||
booking = models.OneToOneField(Booking, on_delete=models.CASCADE, verbose_name=_('Booking'))
|
||||
lock_code = models.CharField(verbose_name=_('Lock code'), max_length=64, blank=False)
|
||||
expiration_datetime = models.DateTimeField(
|
||||
verbose_name=_('Lease expiration time'), default=get_lease_expiration
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Lease')
|
||||
verbose_name_plural = _('Leases')
|
||||
|
||||
@classmethod
|
||||
def clean(cls):
|
||||
'''Clean objects linked to leases.'''
|
||||
|
||||
# Delete expired meeting's events, bookings and leases.'''
|
||||
Event.objects.filter(agenda__kind='meetings', booking__lease__expiration_datetime__lt=now()).delete()
|
||||
|
||||
# Delete expired event's bookings and leases'''
|
||||
Booking.objects.filter(event__agenda__kind='events', lease__expiration_datetime__lt=now()).delete()
|
||||
|
|
|
@ -107,6 +107,8 @@ class FillSlotSerializer(serializers.Serializer):
|
|||
check_overlaps = serializers.BooleanField(default=False)
|
||||
start_time = serializers.TimeField(required=False, allow_null=True)
|
||||
end_time = serializers.TimeField(required=False, allow_null=True)
|
||||
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
|
||||
confirm_after_lock = serializers.BooleanField(default=False)
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
|
@ -449,6 +451,7 @@ class DatetimesSerializer(DateRangeSerializer):
|
|||
events = serializers.CharField(required=False, max_length=32, allow_blank=True)
|
||||
hide_disabled = serializers.BooleanField(default=False)
|
||||
bypass_delays = serializers.BooleanField(default=False)
|
||||
lock_code = serializers.CharField(max_length=64, required=False, allow_blank=False, trim_whitespace=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.urls import path, re_path
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from . import views
|
||||
|
||||
|
@ -38,6 +38,11 @@ urlpatterns = [
|
|||
views.agendas_events_fillslots,
|
||||
name='api-agendas-events-fillslots',
|
||||
),
|
||||
path(
|
||||
'agendas/events/fillslots/<uuid:request_uuid>/revert/',
|
||||
views.agendas_events_fillslots_revert,
|
||||
name='api-agendas-events-fillslots-revert',
|
||||
),
|
||||
path(
|
||||
'agendas/events/check-status/',
|
||||
views.agendas_events_check_status,
|
||||
|
@ -145,4 +150,5 @@ urlpatterns = [
|
|||
),
|
||||
path('statistics/', views.statistics_list, name='api-statistics-list'),
|
||||
path('statistics/bookings/', views.bookings_statistics, name='api-statistics-bookings'),
|
||||
path('ants/', include('chrono.apps.ants_hub.api_urls')),
|
||||
]
|
||||
|
|
|
@ -50,6 +50,7 @@ from chrono.agendas.models import (
|
|||
BookingColor,
|
||||
Desk,
|
||||
Event,
|
||||
Lease,
|
||||
MeetingType,
|
||||
SharedCustodyAgenda,
|
||||
Subscription,
|
||||
|
@ -128,17 +129,18 @@ def get_event_places(event):
|
|||
available = event.remaining_places
|
||||
places = {
|
||||
'total': event.places,
|
||||
'reserved': event.booked_places,
|
||||
'reserved': event.get_booked_places(),
|
||||
'available': available,
|
||||
'full': event.full,
|
||||
'full': event.get_full(),
|
||||
'has_waiting_list': False,
|
||||
}
|
||||
if event.waiting_list_places:
|
||||
places['has_waiting_list'] = True
|
||||
places['waiting_list_total'] = event.waiting_list_places
|
||||
places['waiting_list_reserved'] = event.booked_waiting_list_places
|
||||
# use the alternate computation of booked waiting place if a lock_code is currently used
|
||||
places['waiting_list_reserved'] = event.get_booked_waiting_list_places()
|
||||
places['waiting_list_available'] = event.remaining_waiting_list_places
|
||||
places['waiting_list_activated'] = event.booked_waiting_list_places > 0 or available <= 0
|
||||
places['waiting_list_activated'] = event.get_booked_waiting_list_places() > 0 or available <= 0
|
||||
# 'waiting_list_activated' means next booking will go into the waiting list
|
||||
|
||||
return places
|
||||
|
@ -459,7 +461,16 @@ def get_minutes_from_request(request):
|
|||
return serializer.validated_data.get('minutes')
|
||||
|
||||
|
||||
def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None):
|
||||
def make_booking(
|
||||
event,
|
||||
payload,
|
||||
extra_data,
|
||||
primary_booking=None,
|
||||
in_waiting_list=False,
|
||||
color=None,
|
||||
request_uuid=None,
|
||||
previous_state=None,
|
||||
):
|
||||
out_of_min_delay = False
|
||||
if event.agenda.min_booking_datetime and event.start_datetime < event.agenda.min_booking_datetime:
|
||||
out_of_min_delay = True
|
||||
|
@ -486,6 +497,8 @@ def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_li
|
|||
end_time=payload.get('end_time'),
|
||||
extra_data=extra_data,
|
||||
color=color,
|
||||
request_uuid=request_uuid,
|
||||
previous_state=previous_state,
|
||||
)
|
||||
|
||||
|
||||
|
@ -623,6 +636,7 @@ class Datetimes(APIView):
|
|||
bookable_events = bookable_events_raw or 'future'
|
||||
book_past = bookable_events in ['all', 'past']
|
||||
book_future = bookable_events in ['all', 'future']
|
||||
lock_code = payload.get('lock_code')
|
||||
|
||||
entries = Event.objects.none()
|
||||
if book_past:
|
||||
|
@ -637,6 +651,8 @@ class Datetimes(APIView):
|
|||
bypass_delays=payload.get('bypass_delays'),
|
||||
)
|
||||
entries = Event.annotate_queryset_for_user(entries, user_external_id)
|
||||
if lock_code:
|
||||
entries = Event.annotate_queryset_for_lock_code(entries, lock_code=lock_code)
|
||||
entries = entries.order_by('start_datetime', 'duration', 'label')
|
||||
|
||||
if payload['hide_disabled']:
|
||||
|
@ -698,6 +714,7 @@ class MultipleAgendasDatetimes(APIView):
|
|||
show_only_subscribed = bool('subscribed' in payload)
|
||||
with_status = bool(payload.get('with_status'))
|
||||
check_overlaps = bool(payload.get('check_overlaps'))
|
||||
lock_code = payload.get('lock_code')
|
||||
|
||||
entries = Event.objects.none()
|
||||
if agendas:
|
||||
|
@ -714,6 +731,9 @@ class MultipleAgendasDatetimes(APIView):
|
|||
show_out_of_minimal_delay=show_past_events,
|
||||
)
|
||||
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status)
|
||||
if lock_code:
|
||||
Event.annotate_queryset_for_lock_code(entries, lock_code)
|
||||
|
||||
if check_overlaps:
|
||||
entries = Event.annotate_queryset_with_overlaps(entries)
|
||||
if show_only_subscribed:
|
||||
|
@ -812,6 +832,12 @@ class MeetingDatetimes(APIView):
|
|||
N_('user_external_id and exclude_user_external_id have different values')
|
||||
)
|
||||
|
||||
lock_code = request.GET.get('lock_code', None)
|
||||
if lock_code is not None:
|
||||
lock_code = lock_code.strip()
|
||||
if lock_code == '':
|
||||
raise APIErrorBadRequest(_('lock_code must not be empty'))
|
||||
|
||||
# Generate an unique slot for each possible meeting [start_datetime,
|
||||
# end_datetime] range.
|
||||
# First use get_all_slots() to get each possible meeting by desk and
|
||||
|
@ -835,6 +861,7 @@ class MeetingDatetimes(APIView):
|
|||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
user_external_id=booked_user_external_id or excluded_user_external_id,
|
||||
lock_code=lock_code,
|
||||
)
|
||||
)
|
||||
for slot in sorted(all_slots, key=lambda slot: slot[:3]):
|
||||
|
@ -856,6 +883,7 @@ class MeetingDatetimes(APIView):
|
|||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
user_external_id=booked_user_external_id or excluded_user_external_id,
|
||||
lock_code=lock_code,
|
||||
)
|
||||
)
|
||||
last_slot, slot_agendas = None, set()
|
||||
|
@ -1091,15 +1119,7 @@ class MeetingList(APIView):
|
|||
'id': meeting_type.slug,
|
||||
'duration': meeting_type.duration,
|
||||
'api': {
|
||||
'datetimes_url': request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-agenda-meeting-datetimes',
|
||||
kwargs={
|
||||
'agenda_identifier': agenda.slug,
|
||||
'meeting_identifier': meeting_type.slug,
|
||||
},
|
||||
)
|
||||
),
|
||||
'datetimes_url': request.build_absolute_uri(meeting_type.get_datetimes_url()),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -1126,12 +1146,7 @@ class MeetingInfo(APIView):
|
|||
except MeetingType.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
datetimes_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
'api-agenda-meeting-datetimes',
|
||||
kwargs={'agenda_identifier': agenda.slug, 'meeting_identifier': meeting_type.slug},
|
||||
)
|
||||
)
|
||||
datetimes_url = request.build_absolute_uri(meeting_type.get_datetimes_url())
|
||||
return Response(
|
||||
{
|
||||
'data': {
|
||||
|
@ -1187,7 +1202,8 @@ class EventsAgendaFillslot(APIView):
|
|||
def post(self, request, agenda, slot):
|
||||
return self.fillslot(request=request, agenda=agenda, slot=slot)
|
||||
|
||||
def fillslot(self, request, agenda, slot, retry=False):
|
||||
@transaction.atomic()
|
||||
def fillslot(self, request, agenda, slot):
|
||||
known_body_params = set(request.query_params).intersection(
|
||||
{'label', 'user_name', 'backoffice_url', 'user_display_label'}
|
||||
)
|
||||
|
@ -1203,6 +1219,14 @@ class EventsAgendaFillslot(APIView):
|
|||
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
||||
payload = serializer.validated_data
|
||||
|
||||
lock_code = payload.get('lock_code')
|
||||
if lock_code:
|
||||
# cannot apply a default value on the serializer bcause it is used with partial=True
|
||||
confirm_after_lock = payload.get('confirm_after_lock') or False
|
||||
# delete events/bookings with the same lock code in the
|
||||
# same agenda(s) to allow rebooking or confirming
|
||||
Booking.objects.filter(event__agenda=agenda, lease__lock_code=lock_code).delete()
|
||||
|
||||
if 'count' in payload:
|
||||
places_count = payload['count']
|
||||
elif 'count' in request.query_params:
|
||||
|
@ -1250,6 +1274,7 @@ class EventsAgendaFillslot(APIView):
|
|||
|
||||
# search free places. Switch to waiting list if necessary.
|
||||
in_waiting_list = False
|
||||
|
||||
if event.start_datetime > now():
|
||||
if payload.get('force_waiting_list') and not event.waiting_list_places:
|
||||
raise APIError(N_('no waiting list'))
|
||||
|
@ -1257,52 +1282,41 @@ class EventsAgendaFillslot(APIView):
|
|||
if event.waiting_list_places:
|
||||
if (
|
||||
payload.get('force_waiting_list')
|
||||
or (event.booked_places + places_count) > event.places
|
||||
or event.booked_waiting_list_places
|
||||
or (event.get_booked_places() + places_count) > event.places
|
||||
or event.get_booked_waiting_list_places()
|
||||
):
|
||||
# if this is full or there are people waiting, put new bookings
|
||||
# in the waiting list.
|
||||
in_waiting_list = True
|
||||
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
|
||||
if (event.get_booked_waiting_list_places() + places_count) > event.waiting_list_places:
|
||||
raise APIError(N_('sold out'))
|
||||
else:
|
||||
if (event.booked_places + places_count) > event.places:
|
||||
if (event.get_booked_places() + places_count) > event.places:
|
||||
raise APIError(N_('sold out'))
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if to_cancel_booking:
|
||||
cancelled_booking_id = to_cancel_booking.pk
|
||||
to_cancel_booking.cancel()
|
||||
if to_cancel_booking:
|
||||
cancelled_booking_id = to_cancel_booking.pk
|
||||
to_cancel_booking.cancel()
|
||||
|
||||
# now we have a list of events, book them.
|
||||
primary_booking = None
|
||||
for dummy in range(places_count):
|
||||
new_booking = make_booking(
|
||||
event=event,
|
||||
payload=payload,
|
||||
extra_data=extra_data,
|
||||
primary_booking=primary_booking,
|
||||
in_waiting_list=in_waiting_list,
|
||||
)
|
||||
new_booking.save()
|
||||
if primary_booking is None:
|
||||
primary_booking = new_booking
|
||||
except IntegrityError as e:
|
||||
if 'tstzrange_constraint' in str(e):
|
||||
# "optimistic concurrency control", between our availability
|
||||
# check with get_all_slots() and now, new event can have been
|
||||
# created and conflict with the events we want to create, and
|
||||
# so we get an IntegrityError exception. In this case we
|
||||
# restart the fillslot() from the begginning to redo the
|
||||
# availability check and return a proper error to the client.
|
||||
#
|
||||
# To prevent looping, we raise an APIError during the second run
|
||||
# of fillslot().
|
||||
if retry:
|
||||
raise APIError(N_('no more desk available'))
|
||||
return self.fillslot(request=request, agenda=agenda, slot=slot, retry=True)
|
||||
raise
|
||||
# now we have a list of events, book them.
|
||||
primary_booking = None
|
||||
for dummy in range(places_count):
|
||||
new_booking = make_booking(
|
||||
event=event,
|
||||
payload=payload,
|
||||
extra_data=extra_data,
|
||||
primary_booking=primary_booking,
|
||||
in_waiting_list=in_waiting_list,
|
||||
)
|
||||
new_booking.save()
|
||||
if lock_code and not confirm_after_lock:
|
||||
Lease.objects.create(
|
||||
booking=new_booking,
|
||||
lock_code=lock_code,
|
||||
)
|
||||
|
||||
if primary_booking is None:
|
||||
primary_booking = new_booking
|
||||
|
||||
response = {
|
||||
'err': 0,
|
||||
|
@ -1370,6 +1384,11 @@ class MeetingsAgendaFillslot(APIView):
|
|||
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
||||
payload = serializer.validated_data
|
||||
|
||||
lock_code = payload.get('lock_code')
|
||||
if lock_code:
|
||||
# cannot apply a default value on the serializer bcause it is used with partial=True
|
||||
confirm_after_lock = payload.get('confirm_after_lock') or False
|
||||
|
||||
to_cancel_booking = None
|
||||
cancel_booking_id = None
|
||||
if payload.get('cancel_booking_id'):
|
||||
|
@ -1418,6 +1437,7 @@ class MeetingsAgendaFillslot(APIView):
|
|||
meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
|
||||
except (MeetingType.DoesNotExist, ValueError):
|
||||
raise APIErrorBadRequest(N_('invalid meeting type id: %s'), meeting_type_id)
|
||||
|
||||
all_slots = sorted(
|
||||
agenda.get_all_slots(
|
||||
meeting_type,
|
||||
|
@ -1425,6 +1445,7 @@ class MeetingsAgendaFillslot(APIView):
|
|||
user_external_id=user_external_id if exclude_user else None,
|
||||
start_datetime=slot_datetime,
|
||||
end_datetime=slot_datetime + datetime.timedelta(minutes=meeting_type.duration),
|
||||
lock_code=lock_code,
|
||||
),
|
||||
key=lambda slot: slot.start_datetime,
|
||||
)
|
||||
|
@ -1501,6 +1522,12 @@ class MeetingsAgendaFillslot(APIView):
|
|||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if lock_code:
|
||||
# delete events/bookings with the same lock code in the
|
||||
# same agenda(s) to allow rebooking or confirming
|
||||
Event.objects.filter(
|
||||
agenda__in=agenda.get_real_agendas(), booking__lease__lock_code=lock_code
|
||||
).delete()
|
||||
if to_cancel_booking:
|
||||
cancelled_booking_id = to_cancel_booking.pk
|
||||
to_cancel_booking.cancel()
|
||||
|
@ -1516,6 +1543,12 @@ class MeetingsAgendaFillslot(APIView):
|
|||
color=color,
|
||||
)
|
||||
booking.save()
|
||||
if lock_code and not confirm_after_lock:
|
||||
Lease.objects.create(
|
||||
booking=booking,
|
||||
lock_code=lock_code,
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
if 'tstzrange_constraint' in str(e):
|
||||
# "optimistic concurrency control", between our availability
|
||||
|
@ -1599,6 +1632,9 @@ class RecurringFillslots(APIView):
|
|||
if not serializer.is_valid():
|
||||
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
||||
data = serializer.validated_data
|
||||
lock_code = data.get('lock_code')
|
||||
if lock_code:
|
||||
raise APIErrorBadRequest(N_('lock_code is unsupported'), errors=serializer.errors)
|
||||
guardian_external_id = data.get('guardian_external_id')
|
||||
|
||||
start_datetime, end_datetime = data.get('date_start'), data.get('date_end')
|
||||
|
@ -1872,7 +1908,9 @@ class EventsFillslots(APIView):
|
|||
)
|
||||
return self.fillslots(request)
|
||||
|
||||
@transaction.atomic()
|
||||
def fillslots(self, request):
|
||||
request_uuid = uuid.uuid4() if self.multiple_agendas else None
|
||||
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
|
||||
|
||||
serializer = self.serializer_class(
|
||||
|
@ -1884,6 +1922,11 @@ class EventsFillslots(APIView):
|
|||
user_external_id = payload['user_external_id']
|
||||
bypass_delays = payload.get('bypass_delays')
|
||||
check_overlaps = payload.get('check_overlaps')
|
||||
lock_code = payload.get('lock_code')
|
||||
|
||||
if lock_code:
|
||||
confirm_after_lock = payload.get('confirm_after_lock') or False
|
||||
Booking.objects.filter(event__agenda=self.agenda, lease__lock_code=lock_code).delete()
|
||||
|
||||
events = self.get_events(request, payload, start_datetime, end_datetime)
|
||||
|
||||
|
@ -1926,6 +1969,7 @@ class EventsFillslots(APIView):
|
|||
booking__user_external_id=user_external_id,
|
||||
booking__cancellation_datetime__isnull=False,
|
||||
)
|
||||
events_cancelled_to_delete_ids = events_cancelled_to_delete.values_list('pk', flat=True)
|
||||
|
||||
# book only events without active booking for the user
|
||||
events = events.exclude(
|
||||
|
@ -1949,7 +1993,18 @@ class EventsFillslots(APIView):
|
|||
waiting_list_event_ids = [event.pk for event in events if event.in_waiting_list]
|
||||
|
||||
extra_data = get_extra_data(request, payload)
|
||||
bookings = [make_booking(event, payload, extra_data) for event in events]
|
||||
bookings = [
|
||||
make_booking(
|
||||
event,
|
||||
payload,
|
||||
extra_data,
|
||||
request_uuid=request_uuid if self.multiple_agendas else None,
|
||||
previous_state=None
|
||||
if not self.multiple_agendas
|
||||
else ('cancelled' if event.pk in events_cancelled_to_delete_ids else 'unbooked'),
|
||||
)
|
||||
for event in events
|
||||
]
|
||||
|
||||
bookings_to_cancel_out_of_min_delay = Booking.objects.filter(
|
||||
user_external_id=user_external_id,
|
||||
|
@ -1963,44 +2018,52 @@ class EventsFillslots(APIView):
|
|||
events_by_id = {
|
||||
x.id: x for x in (list(events) + events_to_unbook + events_to_unbook_out_of_min_delay)
|
||||
}
|
||||
with transaction.atomic():
|
||||
# cancel existing bookings
|
||||
cancellation_datetime = now()
|
||||
Booking.objects.filter(primary_booking__in=bookings_to_cancel_out_of_min_delay).update(
|
||||
cancellation_datetime=cancellation_datetime,
|
||||
out_of_min_delay=True,
|
||||
|
||||
cancellation_datetime = now()
|
||||
Booking.objects.filter(primary_booking__in=bookings_to_cancel_out_of_min_delay).update(
|
||||
cancellation_datetime=cancellation_datetime,
|
||||
out_of_min_delay=True,
|
||||
request_uuid=request_uuid,
|
||||
previous_state='booked' if self.multiple_agendas else None,
|
||||
)
|
||||
cancelled_events = [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=self.multiple_agendas,
|
||||
)
|
||||
cancelled_events = [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=self.multiple_agendas,
|
||||
)
|
||||
for x in bookings_to_cancel_out_of_min_delay
|
||||
]
|
||||
cancelled_count = bookings_to_cancel_out_of_min_delay.update(
|
||||
cancellation_datetime=cancellation_datetime, out_of_min_delay=True
|
||||
for x in bookings_to_cancel_out_of_min_delay
|
||||
]
|
||||
cancelled_count = bookings_to_cancel_out_of_min_delay.update(
|
||||
cancellation_datetime=cancellation_datetime, out_of_min_delay=True
|
||||
)
|
||||
Booking.objects.filter(primary_booking__in=bookings_to_cancel).update(
|
||||
cancellation_datetime=cancellation_datetime, out_of_min_delay=False
|
||||
)
|
||||
cancelled_events += [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=self.multiple_agendas,
|
||||
)
|
||||
Booking.objects.filter(primary_booking__in=bookings_to_cancel).update(
|
||||
cancellation_datetime=cancellation_datetime, out_of_min_delay=False
|
||||
for x in bookings_to_cancel
|
||||
]
|
||||
cancelled_count += bookings_to_cancel.update(
|
||||
cancellation_datetime=cancellation_datetime,
|
||||
out_of_min_delay=False,
|
||||
request_uuid=request_uuid,
|
||||
previous_state='booked' if self.multiple_agendas else None,
|
||||
)
|
||||
# and delete outdated cancelled bookings
|
||||
Booking.objects.filter(
|
||||
user_external_id=user_external_id, event__in=events_cancelled_to_delete
|
||||
).delete()
|
||||
# create missing bookings
|
||||
created_bookings = Booking.objects.bulk_create(bookings)
|
||||
if lock_code and not confirm_after_lock:
|
||||
Lease.objects.bulk_create(
|
||||
Lease(booking=created_booking, lock_code=lock_code) for created_booking in created_bookings
|
||||
)
|
||||
cancelled_events += [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=self.multiple_agendas,
|
||||
)
|
||||
for x in bookings_to_cancel
|
||||
]
|
||||
cancelled_count += bookings_to_cancel.update(
|
||||
cancellation_datetime=cancellation_datetime, out_of_min_delay=False
|
||||
)
|
||||
# and delete outdated cancelled bookings
|
||||
Booking.objects.filter(
|
||||
user_external_id=user_external_id, event__in=events_cancelled_to_delete
|
||||
).delete()
|
||||
# create missing bookings
|
||||
created_bookings = Booking.objects.bulk_create(bookings)
|
||||
|
||||
# don't reload agendas and events types
|
||||
for event in events:
|
||||
|
@ -2029,6 +2092,10 @@ class EventsFillslots(APIView):
|
|||
}
|
||||
if not self.multiple_agendas:
|
||||
response['bookings_ics_url'] += '&agenda=%s' % self.agenda.slug
|
||||
if request_uuid:
|
||||
response['revert_url'] = request.build_absolute_uri(
|
||||
reverse('api-agendas-events-fillslots-revert', args=[request_uuid])
|
||||
)
|
||||
return Response(response)
|
||||
|
||||
def get_events(self, request, payload, start_datetime, end_datetime):
|
||||
|
@ -2131,6 +2198,74 @@ class MultipleAgendasEventsFillslots(EventsFillslots):
|
|||
agendas_events_fillslots = MultipleAgendasEventsFillslots.as_view()
|
||||
|
||||
|
||||
class MultipleAgendasEventsFillslotsRevert(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def post(self, request, request_uuid):
|
||||
bookings = Booking.objects.filter(request_uuid=request_uuid)
|
||||
bookings_to_cancel = []
|
||||
bookings_to_book = []
|
||||
bookings_to_delete = []
|
||||
for booking in bookings:
|
||||
if booking.previous_state == 'unbooked':
|
||||
bookings_to_delete.append(booking)
|
||||
if booking.previous_state == 'booked':
|
||||
bookings_to_book.append(booking)
|
||||
if booking.previous_state == 'cancelled':
|
||||
bookings_to_cancel.append(booking)
|
||||
|
||||
events = Event.objects.filter(
|
||||
pk__in=[b.event_id for b in bookings_to_cancel + bookings_to_book + bookings_to_delete]
|
||||
).prefetch_related('agenda')
|
||||
events_by_id = {x.id: x for x in events}
|
||||
with transaction.atomic():
|
||||
cancellation_datetime = now()
|
||||
cancelled_events = [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=True,
|
||||
)
|
||||
for x in bookings_to_cancel
|
||||
]
|
||||
cancelled_count = Booking.objects.filter(pk__in=[b.pk for b in bookings_to_cancel]).update(
|
||||
cancellation_datetime=cancellation_datetime
|
||||
)
|
||||
booked_events = [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=True,
|
||||
)
|
||||
for x in bookings_to_book
|
||||
]
|
||||
booked_count = Booking.objects.filter(pk__in=[b.pk for b in bookings_to_book]).update(
|
||||
cancellation_datetime=None
|
||||
)
|
||||
deleted_events = [
|
||||
get_short_event_detail(
|
||||
request,
|
||||
events_by_id[x.event_id],
|
||||
multiple_agendas=True,
|
||||
)
|
||||
for x in bookings_to_delete
|
||||
]
|
||||
deleted_count = Booking.objects.filter(pk__in=[b.pk for b in bookings_to_delete]).delete()[0]
|
||||
response = {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': cancelled_count,
|
||||
'cancelled_events': cancelled_events,
|
||||
'booked_booking_count': booked_count,
|
||||
'booked_events': booked_events,
|
||||
'deleted_booking_count': deleted_count,
|
||||
'deleted_events': deleted_events,
|
||||
}
|
||||
return Response(response)
|
||||
|
||||
|
||||
agendas_events_fillslots_revert = MultipleAgendasEventsFillslotsRevert.as_view()
|
||||
|
||||
|
||||
class MultipleAgendasEvents(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
serializer_class = serializers.SlotsSerializer
|
||||
|
@ -2812,7 +2947,7 @@ class AcceptBooking(APIView):
|
|||
response = {
|
||||
'err': 0,
|
||||
'booking_id': booking.pk,
|
||||
'overbooked_places': max(0, event.booked_places - event.places),
|
||||
'overbooked_places': max(0, event.get_booked_places() - event.places),
|
||||
}
|
||||
return Response(response)
|
||||
|
||||
|
@ -2900,7 +3035,9 @@ class ResizeBooking(APIView):
|
|||
# total places for the event (in waiting or main list, depending on the primary booking location)
|
||||
places = event.waiting_list_places if booking.in_waiting_list else event.places
|
||||
# total booked places for the event (in waiting or main list, depending on the primary booking location)
|
||||
booked_places = event.booked_waiting_list_places if booking.in_waiting_list else event.booked_places
|
||||
booked_places = (
|
||||
event.get_booked_waiting_list_places() if booking.in_waiting_list else event.get_booked_places()
|
||||
)
|
||||
|
||||
# places to book for this primary booking
|
||||
primary_wanted_places = payload['count']
|
||||
|
@ -2920,7 +3057,7 @@ class ResizeBooking(APIView):
|
|||
if booking.in_waiting_list:
|
||||
# booking in waiting list: can not be overbooked
|
||||
raise APIError(N_('sold out'), err=3)
|
||||
if event.booked_places <= event.places:
|
||||
if event.get_booked_places() <= event.places:
|
||||
# in main list and no overbooking for the moment: can not be overbooked
|
||||
raise APIError(N_('sold out'), err=3)
|
||||
return self.increase(booking, secondary_bookings, primary_booked_places, primary_wanted_places)
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('check-duplicate/', views.CheckDuplicateAPI.as_view(), name='api-ants-check-duplicate'),
|
||||
]
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
|
@ -71,3 +72,17 @@ def push_rendez_vous_disponibles(payload):
|
|||
return True
|
||||
except (TypeError, KeyError, requests.RequestException) as e:
|
||||
raise AntsHubException(str(e))
|
||||
|
||||
|
||||
def check_duplicate(identifiants_predemande: list):
|
||||
params = [
|
||||
('identifiant_predemande', identifiant_predemande)
|
||||
for identifiant_predemande in identifiants_predemande
|
||||
]
|
||||
session = make_http_session()
|
||||
try:
|
||||
response = session.get(make_url('rdv-status/'), params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (ValueError, requests.RequestException) as e:
|
||||
return {'err': 1, 'err_desc': f'ANTS hub is unavailable: {e!r}'}
|
||||
|
|
|
@ -224,6 +224,7 @@ class Place(models.Model):
|
|||
event__desk__agenda__in=agendas,
|
||||
event__start_datetime__gt=now(),
|
||||
extra_data__ants_identifiant_predemande__isnull=False,
|
||||
lease__isnull=True,
|
||||
)
|
||||
.exclude(extra_data__ants_identifiant_predemande='')
|
||||
.values_list(
|
||||
|
|
|
@ -14,14 +14,21 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_noop as N_
|
||||
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
|
||||
from rest_framework import permissions
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chrono.api.utils import APIErrorBadRequest, Response
|
||||
|
||||
from . import hub, models
|
||||
|
||||
|
@ -249,3 +256,33 @@ class Synchronize(TemplateView):
|
|||
ants_hub_city_push.spool(domain=getattr(tenant, 'domain_url', None))
|
||||
else:
|
||||
models.City.push()
|
||||
|
||||
|
||||
class CheckDuplicateAPI(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
identifiant_predemande_re = re.compile(r'^[A-Z0-9]{10}$')
|
||||
|
||||
def post(self, request):
|
||||
if not settings.CHRONO_ANTS_HUB_URL:
|
||||
raise APIErrorBadRequest(N_('CHRONO_ANTS_HUB_URL is not configured'))
|
||||
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
identifiant_predemande = data.get('identifiant_predemande', request.GET.get('identifiant_predemande'))
|
||||
identifiants_predemande = identifiant_predemande or []
|
||||
|
||||
if isinstance(identifiants_predemande, str):
|
||||
identifiants_predemande = identifiants_predemande.split(',')
|
||||
|
||||
if not isinstance(identifiants_predemande, list):
|
||||
raise APIErrorBadRequest(
|
||||
N_('identifiant_predemande must be a list of identifiants separated by commas: %s'),
|
||||
repr(identifiants_predemande),
|
||||
)
|
||||
|
||||
identifiants_predemande = list(filter(None, map(str.upper, map(str.strip, identifiants_predemande))))
|
||||
|
||||
if not identifiants_predemande:
|
||||
return Response({'err': 0, 'data': {'accept_rdv': True}})
|
||||
|
||||
return Response(hub.check_duplicate(identifiants_predemande))
|
||||
|
|
|
@ -7,8 +7,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: chrono 0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-11-02 13:47+0000\n"
|
||||
"PO-Revision-Date: 2023-11-02 14:47+0100\n"
|
||||
"POT-Creation-Date: 2023-11-16 12:13+0100\n"
|
||||
"PO-Revision-Date: 2023-11-16 12:14+0100\n"
|
||||
"Last-Translator: Frederic Peters <fpeters@entrouvert.com>\n"
|
||||
"Language: French\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
|
@ -893,6 +893,26 @@ msgstr "Rôle de gestion"
|
|||
msgid "Holidays calendar"
|
||||
msgstr "Calendrier des vacances"
|
||||
|
||||
#: agendas/models.py manager/forms.py
|
||||
msgid "Booking"
|
||||
msgstr "Réservation"
|
||||
|
||||
#: agendas/models.py
|
||||
msgid "Lock code"
|
||||
msgstr "Code de verrouillage"
|
||||
|
||||
#: agendas/models.py
|
||||
msgid "Lease expiration time"
|
||||
msgstr "Date d'expiration du bail"
|
||||
|
||||
#: agendas/models.py
|
||||
msgid "Lease"
|
||||
msgstr "Bail"
|
||||
|
||||
#: agendas/models.py
|
||||
msgid "Leases"
|
||||
msgstr "Baux"
|
||||
|
||||
#: agendas/templates/agendas/event_notification_body.html
|
||||
#: agendas/templates/agendas/events_reminder_body.html
|
||||
#: agendas/templates/agendas/events_reminder_body.txt
|
||||
|
@ -1264,6 +1284,10 @@ msgstr ""
|
|||
msgid "it is not possible to change kind value"
|
||||
msgstr "il n’est pas possible de modifier le type"
|
||||
|
||||
#: api/views.py
|
||||
msgid "lock_code must not be empty"
|
||||
msgstr "Le code de verrouillage (lock_code) ne doit pas être vide."
|
||||
|
||||
#: api/views.py
|
||||
#, python-format
|
||||
msgid "parameters \"%s\" must be included in request body, not query"
|
||||
|
@ -1303,10 +1327,6 @@ msgstr "pas de liste d’attente"
|
|||
msgid "sold out"
|
||||
msgstr "complet"
|
||||
|
||||
#: api/views.py
|
||||
msgid "no more desk available"
|
||||
msgstr "plus de guichet disponible"
|
||||
|
||||
#: api/views.py
|
||||
#, python-format
|
||||
msgid "invalid timeslot_id: %s"
|
||||
|
@ -1322,6 +1342,14 @@ msgstr "mauvais format pour la date/heure : %s"
|
|||
msgid "invalid meeting type id: %s"
|
||||
msgstr "identifiant de type de rendez-vous invalide : %s"
|
||||
|
||||
#: api/views.py
|
||||
msgid "no more desk available"
|
||||
msgstr "plus de guichet disponible"
|
||||
|
||||
#: api/views.py
|
||||
msgid "lock_code is unsupported"
|
||||
msgstr "Le paramètre lock_code n'est pas géré."
|
||||
|
||||
#: api/views.py
|
||||
#, python-format
|
||||
msgid "Some events occur at the same time: %s"
|
||||
|
@ -1844,6 +1872,17 @@ msgstr "%(mt_label)s (%(mt_duration)s minutes)"
|
|||
msgid "Synchronization has been launched."
|
||||
msgstr "La synchronisation a été lancée."
|
||||
|
||||
#: apps/ants_hub/views.py
|
||||
msgid "CHRONO_ANTS_HUB_URL is not configured"
|
||||
msgstr "La variable CHRONO_ANTS_HUB_URL n'est pas configurée."
|
||||
|
||||
#: apps/ants_hub/views.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"identifiant_predemande must be a list of identifiants separated by commas: %s"
|
||||
msgstr ""
|
||||
"identifiant_predemande doit être une liste d'identifiants séparés par des virgules: %s"
|
||||
|
||||
#: manager/forms.py
|
||||
msgid "Desk 1"
|
||||
msgstr "Guichet 1"
|
||||
|
@ -2246,10 +2285,6 @@ msgstr "pas de courriel"
|
|||
msgid "no phone number"
|
||||
msgstr "pas de numéro de téléphone"
|
||||
|
||||
#: manager/forms.py
|
||||
msgid "Booking"
|
||||
msgstr "Réservation"
|
||||
|
||||
#: manager/forms.py
|
||||
msgid "Only the last ten bookings are displayed."
|
||||
msgstr "Seules les dix dernières réservations sont affichées."
|
||||
|
|
|
@ -609,10 +609,11 @@ div#main-content.partial-booking-dayview {
|
|||
--zebra-color: hsla(0,0%,0%,0.05);
|
||||
--separator-color: white;
|
||||
--separator-size: 2px;
|
||||
--padding: 0.5rem;
|
||||
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
padding: var(--padding);
|
||||
|
||||
&--hours-list {
|
||||
background: white;
|
||||
|
@ -636,6 +637,7 @@ div#main-content.partial-booking-dayview {
|
|||
}
|
||||
&--registrant-items {
|
||||
margin-top: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
&--registrant {
|
||||
display: flex;
|
||||
|
@ -712,6 +714,22 @@ div#main-content.partial-booking-dayview {
|
|||
}
|
||||
}
|
||||
}
|
||||
&--hour-indicator-wrapper {
|
||||
position: absolute;
|
||||
inset: 0 var(--padding) 0 var(--padding);
|
||||
@media (min-width: 761px) {
|
||||
margin-left: var(--registrant-name-width);
|
||||
}
|
||||
}
|
||||
&--hour-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--red);
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-table.partial-bookings .booking {
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<a class="date-next pk-button" href="{{ view.get_next_day_url }}"><span class="sr-only">{% trans "Next day" %}</span></a>
|
||||
</span>
|
||||
<h2 class="date-nav">
|
||||
<span class="date-title">{{ view.date|date:"l j F Y" }}</span>
|
||||
<time datetime="{{ view.date|date:'Y-m-d' }}" class="date-title">{{ view.date|date:"l j F Y" }}</time>
|
||||
<button class="date-picker-opener"><span class="sr-only">{% trans "Pick a date" %}</span></button>
|
||||
{% with selected_day=view.date|date:"j" selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
|
||||
<div class="date-picker" style="display: none">
|
||||
|
|
|
@ -44,7 +44,34 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="partial-booking" style="--nb-hours: {{ hours|length }}">
|
||||
<div
|
||||
class="partial-booking"
|
||||
style="--nb-hours: {{ hours|length }}"
|
||||
data-start-datetime="{{ start_datetime.isoformat }}"
|
||||
data-end-datetime="{{ end_datetime.isoformat }}"
|
||||
>
|
||||
{% if view.date.date == today %}
|
||||
<div class="partial-booking--hour-indicator-wrapper" aria-hidden="true">
|
||||
<div class="partial-booking--hour-indicator" hidden></div>
|
||||
<script>
|
||||
const hour_indicator = (function() {
|
||||
const indicator = document.querySelector('.partial-booking--hour-indicator')
|
||||
|
||||
const div_container = document.querySelector('.partial-booking')
|
||||
const start = new Date(div_container.dataset.startDatetime).getTime()
|
||||
const end = new Date(div_container.dataset.endDatetime).getTime() + 3600000 - start
|
||||
|
||||
const indicator_position = function() {
|
||||
const now = Date.now() - start
|
||||
indicator.style.left = now * 100 / end + "%"
|
||||
}
|
||||
indicator_position();
|
||||
setInterval(indicator_position, 60000)
|
||||
indicator.hidden = false;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="partial-booking--hours-list" aria-hidden="true">
|
||||
{% for hour in hours %}
|
||||
<div class="partial-booking--hour">{{ hour|time:"H" }} h</div>
|
||||
|
|
|
@ -1645,6 +1645,10 @@ class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
|
|||
end_time = datetime.time(min(max_time.hour + 1, 23), 0)
|
||||
context['hours'] = [datetime.time(hour=i) for i in range(start_time.hour, end_time.hour + 1)]
|
||||
|
||||
context['today'] = localtime().date()
|
||||
context['start_datetime'] = datetime.datetime.combine(self.date.date(), start_time)
|
||||
context['end_datetime'] = datetime.datetime.combine(self.date.date(), end_time)
|
||||
|
||||
opening_range_minutes = (
|
||||
(end_time.hour + 1) * 60 + end_time.minute - (start_time.hour * 60 + start_time.minute)
|
||||
)
|
||||
|
@ -4653,7 +4657,7 @@ class PartialBookingCheckView(ViewableAgendaMixin, TemplateView):
|
|||
for form in forms
|
||||
if form.cleaned_data['presence'] is not None
|
||||
]
|
||||
intervals.sort(key=lambda x: x[0])
|
||||
intervals.sort(key=lambda x: x.start)
|
||||
|
||||
for i in range(1, len(intervals)):
|
||||
if intervals[i - 1].end > intervals[i].start:
|
||||
|
|
|
@ -178,6 +178,9 @@ MELLON_IDENTITY_PROVIDERS = []
|
|||
# (see http://docs.python-requests.org/en/master/user/advanced/#proxies)
|
||||
REQUESTS_PROXIES = None
|
||||
|
||||
# default in seconds
|
||||
CHRONO_LOCK_DURATION = 10 * 60
|
||||
|
||||
# timeout used in python-requests call, in seconds
|
||||
# we use 28s by default: timeout just before web server, which is usually 30s
|
||||
REQUESTS_TIMEOUT = 28
|
||||
|
|
|
@ -21,6 +21,7 @@ spooler-max-tasks = 20
|
|||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command cancel_events --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command send_email_notifications --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command update_event_recurrences --all-tenants -v0
|
||||
cron2 = minute=-5,unique=1 /usr/bin/chrono-manage tenant_command clean_leases --all-tenants -v0
|
||||
# every fifteen minutes
|
||||
cron2 = minute=-15,unique=1 /usr/bin/chrono-manage tenant_command sync-ants-hub --all-tenants
|
||||
# hourly
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def test_authorization(app, user):
|
||||
app.post('/api/ants/check-duplicate/', status=401)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
user = User(username='john.doe', first_name='John', last_name='Doe', email='john.doe@example.net')
|
||||
user.set_password('password')
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_app(user, app):
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
return app
|
||||
|
||||
|
||||
class TestCheckDuplicateAPI:
|
||||
def test_not_configured(self, auth_app):
|
||||
resp = auth_app.post('/api/ants/check-duplicate/', status=400)
|
||||
assert resp.json == {
|
||||
'err': 1,
|
||||
'err_class': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
'err_desc': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
'reason': 'CHRONO_ANTS_HUB_URL is not configured',
|
||||
}
|
||||
|
||||
def test_input_empty(self, hub, auth_app):
|
||||
resp = auth_app.post('/api/ants/check-duplicate/')
|
||||
assert resp.json == {'data': {'accept_rdv': True}, 'err': 0}
|
||||
|
||||
@mock.patch('chrono.apps.ants_hub.hub.check_duplicate')
|
||||
def test_proxy(self, check_duplicate_mock, hub, auth_app):
|
||||
# do not care about output
|
||||
check_duplicate_mock.return_value = {'err': 0, 'data': {'xyz': '1234'}}
|
||||
|
||||
# GET param
|
||||
resp = auth_app.post('/api/ants/check-duplicate/?identifiant_predemande= ABCdE12345, ,1234567890 ')
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['ABCDE12345', '1234567890']
|
||||
|
||||
# JSON payload as string
|
||||
resp = auth_app.post_json(
|
||||
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
|
||||
params={'identifiant_predemande': ' XBCdE12345, ,1234567890 '},
|
||||
)
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['XBCDE12345', '1234567890']
|
||||
|
||||
# JSON payload as list
|
||||
resp = auth_app.post_json(
|
||||
'/api/ants/check-duplicate/?identifiant_predemande=XYZ',
|
||||
params={'identifiant_predemande': [' YBCdE12345', ' ', '1234567890 ']},
|
||||
)
|
||||
assert resp.json == {'err': 0, 'data': {'xyz': '1234'}}
|
||||
assert check_duplicate_mock.call_args[0][0] == ['YBCDE12345', '1234567890']
|
|
@ -18,7 +18,7 @@ import pytest
|
|||
import requests
|
||||
import responses
|
||||
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, ping, push_rendez_vous_disponibles
|
||||
from chrono.apps.ants_hub.hub import AntsHubException, check_duplicate, ping, push_rendez_vous_disponibles
|
||||
|
||||
|
||||
def test_ping_timeout(hub):
|
||||
|
@ -67,3 +67,37 @@ def test_push_rendez_vous_disponibles_application_error(hub):
|
|||
)
|
||||
with pytest.raises(AntsHubException, match='overload'):
|
||||
push_rendez_vous_disponibles({})
|
||||
|
||||
|
||||
class TestCheckDuplicate:
|
||||
def test_status_500(self, hub):
|
||||
hub.add(responses.GET, 'https://toto:@ants-hub.example.com/api/chrono/rdv-status/', status=500)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 1,
|
||||
'err_desc': "ANTS hub is unavailable: HTTPError('500 Server Error: Internal Server Error for url: https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111')",
|
||||
}
|
||||
|
||||
def test_timeout(self, hub):
|
||||
hub.add(
|
||||
responses.GET,
|
||||
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/',
|
||||
body=requests.Timeout('boom!'),
|
||||
)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 1,
|
||||
'err_desc': "ANTS hub is unavailable: Timeout('boom!')",
|
||||
}
|
||||
|
||||
def test_ok(self, hub):
|
||||
hub.add(
|
||||
responses.GET,
|
||||
'https://toto:@ants-hub.example.com/api/chrono/rdv-status/?identifiant_predemande=AAAAAAAAAA&identifiant_predemande=1111111111',
|
||||
json={
|
||||
'err': 0,
|
||||
'data': {},
|
||||
},
|
||||
)
|
||||
assert check_duplicate(['A' * 10, '1' * 10]) == {
|
||||
'err': 0,
|
||||
'data': {},
|
||||
}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
from chrono.agendas.models import Agenda, Booking, Event, EventsType
|
||||
from chrono.agendas.models import Agenda, Booking, Event, EventsType, Lease
|
||||
from chrono.utils.timezone import localtime, now
|
||||
from tests.utils import build_event_agenda
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
@ -52,6 +54,7 @@ def test_api_events_fillslots(app, user):
|
|||
resp.json['bookings_ics_url']
|
||||
== 'http://testserver/api/bookings/ics/?user_external_id=user_id&agenda=foo-bar'
|
||||
)
|
||||
assert 'revert_url' not in resp.json
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
|
@ -588,3 +591,257 @@ def test_api_events_fillslots_partial_bookings(app, user):
|
|||
resp.json['errors']['non_field_errors'][0]
|
||||
== 'must include start_time and end_time for partial bookings agenda'
|
||||
)
|
||||
|
||||
|
||||
def test_lock_code(app, user, freezer):
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event 1': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# list events
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
# book first one
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
# list events with lock code
|
||||
resp = app.get(agenda.get_datetimes_url(), params={'lock_code': 'MYLOCK', 'hide_disabled': 'true'})
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
# re-book first one without lock code
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url)
|
||||
assert resp.json['err'] == 1
|
||||
|
||||
# rebook first one with lock code
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# confirm booking
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code, after 30 minutes slot is still booked
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
|
||||
def test_lock_code_expiration(app, user, freezer):
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event 1': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# list events
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
|
||||
# book first one
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# list events without lock code
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 0
|
||||
assert slot['places']['full'] is True
|
||||
|
||||
# list events without lock code, after 30 minutes slot is available
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
call_command('clean_leases')
|
||||
|
||||
resp = app.get(agenda.get_datetimes_url())
|
||||
slot = resp.json['data'][0]
|
||||
assert slot['places']['available'] == 1
|
||||
assert slot['places']['full'] is False
|
||||
|
||||
|
||||
def test_api_events_fillslots_with_lock_code(app, user, freezer):
|
||||
freezer.move_to('2021-09-06 12:00')
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
'Event 2': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
|
||||
params = {
|
||||
'user_external_id': 'user_id',
|
||||
'check_overlaps': True,
|
||||
'slots': 'event,event-2',
|
||||
'lock_code': 'MYLOCK',
|
||||
}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'event'
|
||||
assert (
|
||||
resp.json['booked_events'][0]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event).latest('pk').pk
|
||||
)
|
||||
assert resp.json['booked_events'][1]['id'] == 'event-2'
|
||||
assert (
|
||||
resp.json['booked_events'][1]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
|
||||
)
|
||||
assert len(resp.json['waiting_list_events']) == 0
|
||||
assert resp.json['cancelled_booking_count'] == 0
|
||||
assert len(resp.json['cancelled_events']) == 0
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
||||
# rebooking, nothing change
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
||||
params['confirm_after_lock'] = 'true'
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
response = app.get(agenda.get_datetimes_url() + '?lock_code=MYLOCK')
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
|
||||
def test_api_events_fillslots_with_lock_code_expiration(app, user, freezer):
|
||||
freezer.move_to('2021-09-06 12:00')
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
agenda = build_event_agenda(
|
||||
events={
|
||||
'Event': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
'Event 2': {
|
||||
'start_datetime': now() + datetime.timedelta(days=1),
|
||||
'places': 2,
|
||||
'waiting_list_places': 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug
|
||||
params = {
|
||||
'user_external_id': 'user_id',
|
||||
'check_overlaps': True,
|
||||
'slots': 'event,event-2',
|
||||
'lock_code': 'MYLOCK',
|
||||
}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json(fillslots_url, params=params)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'event'
|
||||
assert (
|
||||
resp.json['booked_events'][0]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event).latest('pk').pk
|
||||
)
|
||||
assert resp.json['booked_events'][1]['id'] == 'event-2'
|
||||
assert (
|
||||
resp.json['booked_events'][1]['booking']['id']
|
||||
== Booking.objects.filter(event=agenda._event_2).latest('pk').pk
|
||||
)
|
||||
assert len(resp.json['waiting_list_events']) == 0
|
||||
assert resp.json['cancelled_booking_count'] == 0
|
||||
assert len(resp.json['cancelled_events']) == 0
|
||||
|
||||
events = Event.objects.all()
|
||||
assert events.filter(booked_places=1).count() == 2
|
||||
assert Booking.objects.count() == 2
|
||||
assert Lease.objects.count() == 2
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 1
|
||||
assert response.json['data'][0]['places']['reserved'] == 1
|
||||
assert response.json['data'][1]['places']['available'] == 1
|
||||
assert response.json['data'][1]['places']['reserved'] == 1
|
||||
|
||||
freezer.move_to('2021-09-06 13:00')
|
||||
call_command('clean_leases')
|
||||
|
||||
response = app.get(agenda.get_datetimes_url())
|
||||
assert response.json['data'][0]['places']['available'] == 2
|
||||
assert response.json['data'][0]['places']['reserved'] == 0
|
||||
assert response.json['data'][1]['places']['available'] == 2
|
||||
assert response.json['data'][1]['places']['reserved'] == 0
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import datetime
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
|
@ -163,7 +164,7 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': event_slugs}
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
|
||||
assert len(ctx.captured_queries) == 17
|
||||
assert len(ctx.captured_queries) == 18
|
||||
assert resp.json['booking_count'] == 2
|
||||
assert len(resp.json['booked_events']) == 2
|
||||
assert resp.json['booked_events'][0]['id'] == 'first-agenda@event'
|
||||
|
@ -179,6 +180,14 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
|
||||
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
|
||||
assert resp.json['bookings_ics_url'] == 'http://testserver/api/bookings/ics/?user_external_id=user_id'
|
||||
request_uuid = first_event.booking_set.get().request_uuid
|
||||
assert request_uuid is not None
|
||||
assert second_event.booking_set.get().request_uuid == request_uuid
|
||||
assert first_event.booking_set.get().previous_state == 'unbooked'
|
||||
assert second_event.booking_set.get().previous_state == 'unbooked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
# booking modification
|
||||
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@event'}
|
||||
|
@ -188,6 +197,12 @@ def test_api_events_fillslots_multiple_agendas(app, user):
|
|||
assert resp.json['cancelled_booking_count'] == 1
|
||||
assert first_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1
|
||||
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 0
|
||||
request_uuid = second_event.booking_set.get().request_uuid
|
||||
assert request_uuid is not None
|
||||
assert second_event.booking_set.get().previous_state == 'booked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
params = {'user_external_id': 'user_id_2', 'slots': event_slugs}
|
||||
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params)
|
||||
|
@ -331,6 +346,16 @@ def test_api_events_fillslots_multiple_agendas_with_cancelled(app, user):
|
|||
)
|
||||
assert Booking.objects.filter(user_external_id='user_id').count() == 3
|
||||
assert Booking.objects.filter(user_external_id='user_id', cancellation_datetime__isnull=True).count() == 3
|
||||
new_booking_1 = Booking.objects.get(event__agenda=agenda_1, event=event_1, user_external_id='user_id')
|
||||
request_uuid = new_booking_1.request_uuid
|
||||
assert request_uuid is not None
|
||||
booking_3 = Booking.objects.get(event__agenda=agenda_2, event=event_3, user_external_id='user_id')
|
||||
assert booking_3.request_uuid == request_uuid
|
||||
assert new_booking_1.previous_state == 'cancelled'
|
||||
assert booking_3.previous_state == 'unbooked'
|
||||
assert (
|
||||
resp.json['revert_url'] == 'http://testserver/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
)
|
||||
|
||||
assert Booking.objects.filter(pk=booking_1.pk).exists() is False # cancelled booking deleted
|
||||
booking_2.refresh_from_db()
|
||||
|
@ -829,3 +854,311 @@ def test_api_events_fillslots_multiple_agendas_partial_bookings(app, user):
|
|||
resp.json['errors']['non_field_errors'][0]
|
||||
== 'must include start_time and end_time for partial bookings agenda'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2021-02-23 14:00')
|
||||
def test_api_events_fillslots_multiple_agendas_revert(app, user):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
request_uuid = uuid.uuid4()
|
||||
|
||||
# no corresponding booking
|
||||
revert_url = '/api/agendas/events/fillslots/%s/revert/' % request_uuid
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
|
||||
booking1 = Booking.objects.create(event=event, request_uuid=uuid.uuid4())
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
|
||||
booking2 = Booking.objects.create(event=event, request_uuid=request_uuid)
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
|
||||
# booking was previously cancelled
|
||||
booking = Booking.objects.create(event=event, request_uuid=request_uuid, previous_state='cancelled')
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 1,
|
||||
'cancelled_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is not None
|
||||
|
||||
# again, but with a cancelled booking
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 1,
|
||||
'cancelled_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is not None
|
||||
|
||||
# booking was previously not cancelled
|
||||
booking.previous_state = 'booked'
|
||||
booking.save()
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 1,
|
||||
'booked_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is None
|
||||
|
||||
# again, but with a not cancelled booking
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 0,
|
||||
'deleted_events': [],
|
||||
'booked_booking_count': 1,
|
||||
'booked_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
booking.refresh_from_db()
|
||||
assert booking.cancellation_datetime is None
|
||||
|
||||
# booking was previously unbooked
|
||||
booking.previous_state = 'unbooked'
|
||||
booking.save()
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 1,
|
||||
'deleted_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
assert Booking.objects.filter(pk=booking.pk).exists() is False
|
||||
|
||||
# again, but with a cancelled booking
|
||||
booking = Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
|
||||
)
|
||||
resp = app.post(revert_url)
|
||||
assert resp.json == {
|
||||
'err': 0,
|
||||
'cancelled_booking_count': 0,
|
||||
'cancelled_events': [],
|
||||
'deleted_booking_count': 1,
|
||||
'deleted_events': [
|
||||
{
|
||||
'agenda_label': 'Foo bar',
|
||||
'check_locked': False,
|
||||
'checked': False,
|
||||
'date': '2021-02-28',
|
||||
'datetime': '2021-02-28 15:00:00',
|
||||
'description': None,
|
||||
'duration': 120,
|
||||
'end_datetime': '2021-02-28 17:00:00',
|
||||
'id': 'foo-bar@event',
|
||||
'invoiced': False,
|
||||
'label': 'Event',
|
||||
'pricing': None,
|
||||
'slug': 'event',
|
||||
'text': 'Event',
|
||||
'url': None,
|
||||
}
|
||||
],
|
||||
'booked_booking_count': 0,
|
||||
'booked_events': [],
|
||||
}
|
||||
booking1.refresh_from_db()
|
||||
assert booking1.cancellation_datetime is None
|
||||
booking2.refresh_from_db()
|
||||
assert booking2.cancellation_datetime is None
|
||||
assert Booking.objects.filter(pk=booking.pk).exists() is False
|
||||
|
||||
# check num queries
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='cancelled', cancellation_datetime=now()
|
||||
)
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='booked', cancellation_datetime=now()
|
||||
)
|
||||
event = Event.objects.create(
|
||||
label='Event',
|
||||
start_datetime=now() + datetime.timedelta(days=5),
|
||||
duration=120,
|
||||
places=1,
|
||||
agenda=agenda,
|
||||
)
|
||||
Booking.objects.create(
|
||||
event=event, request_uuid=request_uuid, previous_state='unbooked', cancellation_datetime=now()
|
||||
)
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
resp = app.post(revert_url)
|
||||
assert len(ctx.captured_queries) == 14
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
# chrono - agendas system
|
||||
# Copyright (C) 2023 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from chrono.agendas.models import Booking, Lease
|
||||
from tests.utils import build_meetings_agenda, build_virtual_agenda
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_meetings_agenda(app, user):
|
||||
'''Test fillslot on meetings agenda with lock_code'''
|
||||
agenda = build_meetings_agenda(
|
||||
'Agenda',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots, with or without a lock
|
||||
resp = app.get(datetimes_url + '?lock_code=MYLOCK')
|
||||
free_slots = len(resp.json['data'])
|
||||
resp = app.get(datetimes_url + '?lock_code=OTHERLOCK')
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(datetimes_url)
|
||||
assert free_slots == len(resp.json['data'])
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'OTHERLOCK'})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# ... unless it's MYLOCK
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK'})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
# can't lock the same timeslot ...
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK'})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "relock")
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# can't book the slot ...
|
||||
resp_booking = app.post_json(fillslot_url)
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url, params={'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "confirm")
|
||||
resp_booking = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
|
||||
def test_meetings_agenda_expiration(app, user, freezer):
|
||||
'''Test fillslot on meetings agenda with lock_code'''
|
||||
agenda = build_meetings_agenda(
|
||||
'Agenda',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots
|
||||
resp = app.get(datetimes_url)
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url, params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# after 30 minutes it is not locked anymore
|
||||
freezer.move_to(datetime.timedelta(minutes=30))
|
||||
call_command('clean_leases')
|
||||
|
||||
resp = app.get(datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
|
||||
def test_meetings_agenda_with_resource_exclusion(app, user):
|
||||
'''Test fillslot on meetings agenda with lock_code and ressources'''
|
||||
agenda1 = build_meetings_agenda(
|
||||
'Agenda 1',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
agenda2 = build_meetings_agenda(
|
||||
'Agenda 2',
|
||||
resources=['Re1'],
|
||||
meeting_types=(30,),
|
||||
desks={'desk-1': ['monday-friday 9:00-12:00 14:00-17:00']},
|
||||
)
|
||||
resource = agenda2._re_re1
|
||||
agenda1_datetimes_url = agenda1._mt_30.get_datetimes_url()
|
||||
agenda2_datetimes_url = agenda2._mt_30.get_datetimes_url()
|
||||
|
||||
# list free slots, with or without a lock
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
|
||||
free_slots = len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
resp = app.get(agenda1_datetimes_url)
|
||||
assert free_slots == len(resp.json['data'])
|
||||
|
||||
# lock a slot
|
||||
fillslot_url = resp.json['data'][2]['api']['fillslot_url']
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# list free slots: one is locked ...
|
||||
resp = app.get(agenda1_datetimes_url, params={'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'OTHERLOCK', 'resources': resource.slug})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# ... unless it's MYLOCK
|
||||
resp = app.get(agenda1_datetimes_url, params={'lock_code': 'MYLOCK', 'resources': resource.slug})
|
||||
assert free_slots == len(resp.json['data'])
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
|
||||
# check slot is also disabled on another agenda with same resource
|
||||
resp = app.get(agenda2_datetimes_url)
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 0
|
||||
resp = app.get(agenda2_datetimes_url, params={'resources': resource.slug})
|
||||
assert len([x for x in resp.json['data'] if x.get('disabled')]) == 1
|
||||
|
||||
# can't lock the same timeslot ...
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK'})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# ... unless with MYLOCK (aka "relock")
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK'})
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.get().lock_code == 'MYLOCK'
|
||||
|
||||
# can't book the slot ...
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1')
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(fillslot_url + '?resources=re1', params={'confirm_after_lock': True})
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
resp_booking = app.post_json(
|
||||
fillslot_url + '?resources=re1', params={'lock_code': 'OTHERLOCK', 'confirm_after_lock': True}
|
||||
)
|
||||
assert resp_booking.json['err'] == 1
|
||||
assert resp_booking.json['reason'] == 'no more desk available'
|
||||
|
||||
# unless with MYLOCK (aka "confirm")
|
||||
resp_booking = app.post_json(
|
||||
fillslot_url + '?resources=re1', params={'lock_code': 'MYLOCK', 'confirm_after_lock': True}
|
||||
)
|
||||
assert resp_booking.json['err'] == 0
|
||||
assert Booking.objects.count() == 1
|
||||
assert Lease.objects.count() == 0
|
||||
|
||||
|
||||
def test_virtual_agenda_with_external_user_id_exclusion(app, user):
|
||||
'''Test lock_code use when excluding an external_user_id'''
|
||||
agenda = build_virtual_agenda(
|
||||
meeting_types=(30,),
|
||||
agendas={
|
||||
'Agenda 1': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 08:00-12:00 14:00-17:00',
|
||||
},
|
||||
},
|
||||
'Agenda 2': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 09:00-12:00',
|
||||
},
|
||||
},
|
||||
'Agenda 3': {
|
||||
'desks': {
|
||||
'desk': 'monday-friday 15:00-17:00',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
datetimes_url = agenda._mt_30.get_datetimes_url()
|
||||
|
||||
resp = app.get(datetimes_url)
|
||||
slots = resp.json['data']
|
||||
# get first slot between 11 and 11:30
|
||||
slot = [slot for slot in slots if ' 11:00:00' in slot['datetime']][0]
|
||||
fillslot_url = slot['api']['fillslot_url']
|
||||
|
||||
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||
resp = app.post_json(fillslot_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
|
||||
assert resp.json['err'] == 0
|
||||
|
||||
# check the lease was created
|
||||
assert Booking.objects.filter(user_external_id='abcd', lease__lock_code='MYLOCK').count() == 1
|
||||
|
||||
# check 11:00 slot is still available
|
||||
resp = app.get(datetimes_url)
|
||||
slots = resp.json['data']
|
||||
assert any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should be available"
|
||||
|
||||
# check 11:00 slot is unavailable when tested with user_external_id
|
||||
resp = app.get(datetimes_url, params={'user_external_id': 'abcd'})
|
||||
slots = resp.json['data']
|
||||
assert not any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should not be available"
|
||||
|
||||
# check 11:00 slot is available if tested with user_external_id *AND* lock_code
|
||||
resp = app.get(datetimes_url, params={'lock_code': 'MYLOCK', 'user_external_id': 'abcd'})
|
||||
slots = resp.json['data']
|
||||
assert any(
|
||||
s['datetime'] == slot['datetime'] for s in slots if not s['disabled']
|
||||
), f"slot {slot['datetime']} should be available"
|
|
@ -244,6 +244,27 @@ def test_manager_partial_bookings_day_view_24_hours(app, admin_user, freezer):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time('2023-03-10 08:00')
|
||||
def test_manager_partial_bookings_day_view_hour_indicator(app, admin_user, freezer):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
|
||||
Event.objects.create(
|
||||
label='Event', start_datetime=now(), end_time=datetime.time(18, 00), places=10, agenda=agenda
|
||||
)
|
||||
|
||||
app = login(app)
|
||||
url = '/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, now().year, now().month, now().day)
|
||||
resp = app.get(url)
|
||||
|
||||
assert resp.pyquery('.partial-booking--hour-indicator')
|
||||
assert resp.pyquery('.partial-booking').attr('data-start-datetime') == '2023-03-10T08:00:00'
|
||||
assert resp.pyquery('.partial-booking').attr('data-end-datetime') == '2023-03-10T19:00:00'
|
||||
|
||||
freezer.move_to('2023-03-11 08:00')
|
||||
|
||||
resp = app.get(url)
|
||||
assert not resp.pyquery('.partial-booking--hour-indicator')
|
||||
|
||||
|
||||
def test_manager_partial_bookings_day_view_multiple_bookings(app, admin_user, freezer):
|
||||
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
|
||||
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
|
||||
|
@ -557,7 +578,7 @@ def test_manager_partial_bookings_multiple_checks(app, admin_user):
|
|||
assert 'Arrival must be before departure.' in resp.text
|
||||
assert 'gadjo-folded' not in resp.text
|
||||
|
||||
# overlap is detected when 2 checks are created at the same time
|
||||
# overlap is detected when 2 checks are created at the same time
|
||||
BookingCheck.objects.all().delete()
|
||||
|
||||
resp.form['presence'] = 'True'
|
||||
|
|
|
@ -9,8 +9,9 @@ from requests.models import Response
|
|||
from chrono.agendas.models import Agenda, TimePeriod
|
||||
from chrono.utils.date import get_weekday_index
|
||||
from chrono.utils.lingo import CheckType, get_agenda_check_types
|
||||
from chrono.utils.timezone import localtime, now
|
||||
|
||||
from .utils import build_agendas, build_meetings_agenda, build_virtual_agenda, paris, utc
|
||||
from .utils import build_agendas, build_event_agenda, build_meetings_agenda, build_virtual_agenda, paris, utc
|
||||
|
||||
|
||||
def test_get_weekday_index():
|
||||
|
@ -266,3 +267,26 @@ def test_paris():
|
|||
|
||||
def test_utc():
|
||||
assert utc('2023-04-19T11:00:00').isoformat() == '2023-04-19T11:00:00+00:00'
|
||||
|
||||
|
||||
def test_build_event_agenda(db):
|
||||
start = now()
|
||||
events = {
|
||||
f'Event {i}': {
|
||||
'start_datetime': start + datetime.timedelta(days=i),
|
||||
'places': 10,
|
||||
}
|
||||
for i in range(10)
|
||||
}
|
||||
agenda = build_event_agenda(label='Agenda 3', events=events)
|
||||
assert agenda.label == 'Agenda 3'
|
||||
assert agenda.slug == 'agenda-3'
|
||||
assert agenda.event_set.count() == 10
|
||||
# ten spaced events
|
||||
assert len(set(agenda.event_set.values_list('start_datetime', flat=True))) == 10
|
||||
# at the same hour
|
||||
assert (
|
||||
len({localtime(start).time() for start in agenda.event_set.values_list('start_datetime', flat=True)})
|
||||
== 1
|
||||
)
|
||||
assert agenda._event_1
|
||||
|
|
|
@ -111,6 +111,15 @@ def build_agenda(kind, label='Agenda', slug=None, **kwargs):
|
|||
return Agenda.objects.create(**agenda_kwargs)
|
||||
|
||||
|
||||
def build_event_agenda(label='Agenda', slug=None, events=None, **kwargs):
|
||||
agenda = build_agenda('events', label=label, slug=slug, **(kwargs or {}))
|
||||
|
||||
for label, event_defn in (events or {}).items():
|
||||
event = Event.objects.create(agenda=agenda, label=label, **event_defn)
|
||||
setattr(agenda, f'_{event.slug.replace("-", "_")}', event)
|
||||
return agenda
|
||||
|
||||
|
||||
def build_meetings_agenda(
|
||||
label='Agenda',
|
||||
slug=None,
|
||||
|
|
Loading…
Reference in New Issue