Compare commits

..

12 Commits

Author SHA1 Message Date
Valentin Deniaud e278707c98 manager: allow separate arrival/departure check for partial bookings (#80047)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-20 10:57:51 +01:00
Thomas Jund 3e478042f6 manager: add hour indicator to partial booking today view (#80043)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 13:04:57 +01:00
Benjamin Dauvergne aff03ffdea translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 12:14:59 +01:00
Benjamin Dauvergne 0543594e30 ants_hub: proxy check-duplicate requests (#81229)
gitea/chrono/pipeline/head This commit looks good Details
To prevent having to configure the HUB URL and credentials in w.c.s.
2023-11-16 12:04:43 +01:00
Benjamin Dauvergne 7fab4c0f41 translation update
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 10:59:31 +01:00
Benjamin Dauvergne 5716d6b3dc ants_hub: do not synchronize locked meetings (#80489)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 10:44:59 +01:00
Benjamin Dauvergne eafa816253 implement locking for event's agendas (#80489)
* add code to clean event's agendas lease/bookings
* add annotation helper method annotate_queryset_for_lock_code() to
  compute corrects places statistics given a lock_code (excluding
  bookings linked to this lock_code)
* use annotate_queryset_for_lock_code() in Datetimes and
  MultipleAgendasDatetimes
* make event's fillslot method completely atomic and add mechanic for
  handling the lock code
* removed handling of IntegrityError which cannot happen for events
* lock_code is for now not supported with RecurringFillslots
2023-11-16 10:40:35 +01:00
Benjamin Dauvergne d6a5861876 implement locking for meeting's agendas (#17685)
* add a Lease model to associate a lock_code to a booking,
* add a new command "clean_leases" run by cron every 5 minutes to clean
  expired leases,
* add new parameter lock_code to get_all_slots() and exclude conflicting
  booking linked to this lock_code if provided,
* accept new lock_code query string parameter in the datetimes endpoints
  (to see available slot minus the locked ones, if the user want to
  change the chosen slot)
* add new parameters lock_code and confirm_after_lock to the fillslot
  endpoint:
  - when lock_code is used without confirm_after_lock:
    1. look for available slots excluding events/booking pairs associated with the given lock_code, by passing lock_code to get_all_slots
    2. before creating the new event/booking pair, clean existing pairs
       associated to the lock code,
    3. after creating the new pair, create a new Lease object with the
       lock code
  - when lock_code is used with confirm_after_lock do all previous steps
    but 3., making a normal meeting booking.
* add tests with lock_code on meeting's datetimes and fillslot use,
  checking exclusion by resources or user_id works with lock_code
2023-11-16 10:37:00 +01:00
Benjamin Dauvergne 2d8912c0a3 agendas: add property for datetimes API url (#80489)
To simplify using datetimes URLs in tests.
2023-11-16 10:23:06 +01:00
Lauréline Guérin 3dac9ed0fb
api: set request_uuid and previous_state on bookings (#83098)
gitea/chrono/pipeline/head This commit looks good Details
2023-11-16 09:19:30 +01:00
Lauréline Guérin 63a575f303
api: revert endpoint (#83098) 2023-11-16 09:19:30 +01:00
Lauréline Guérin 678ac6c1de
agendas: new fields in Booking model (#83098) 2023-11-16 09:19:30 +01:00
26 changed files with 1651 additions and 118 deletions

View File

@ -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()

View File

@ -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),
),
]

View File

@ -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',
},
),
]

View File

@ -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)
@ -4622,3 +4695,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()

View File

@ -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)

View File

@ -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')),
]

View File

@ -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)

View File

@ -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'),
]

View File

@ -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}'}

View File

@ -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(

View File

@ -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))

View File

@ -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 nest 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 dattente"
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."

View File

@ -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 {

View File

@ -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">

View File

@ -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" }}&#x202Fh</div>

View File

@ -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)
)

View File

@ -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

1
debian/uwsgi.ini vendored
View File

@ -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

View File

@ -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']

View File

@ -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': {},
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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))

View File

@ -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

View File

@ -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,