Compare commits
2 Commits
main
...
wip/plages
Author | SHA1 | Date |
---|---|---|
Valentin Deniaud | 58d2eb8582 | |
Valentin Deniaud | 69e8d97245 |
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 3.2.18 on 2023-05-24 13:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('agendas', '0152_auto_20230331_0834'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='agenda',
|
||||||
|
name='partial_bookings',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Allow partial bookings'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='booking',
|
||||||
|
name='end_datetime',
|
||||||
|
field=models.DateTimeField(null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='booking',
|
||||||
|
name='start_datetime',
|
||||||
|
field=models.DateTimeField(null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -284,6 +284,7 @@ class Agenda(models.Model):
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
partial_bookings = models.BooleanField(_('Allow partial bookings'), default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['label']
|
ordering = ['label']
|
||||||
|
@ -2207,6 +2208,9 @@ class Booking(models.Model):
|
||||||
absence_callback_url = models.URLField(blank=True)
|
absence_callback_url = models.URLField(blank=True)
|
||||||
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
|
color = models.ForeignKey(BookingColor, null=True, on_delete=models.SET_NULL, related_name='bookings')
|
||||||
|
|
||||||
|
start_datetime = models.DateTimeField(null=True)
|
||||||
|
end_datetime = models.DateTimeField(null=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def user_name(self):
|
def user_name(self):
|
||||||
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()
|
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()
|
||||||
|
|
|
@ -68,6 +68,8 @@ class AgendaSlugsMixin(metaclass=serializers.SerializerMetaclass):
|
||||||
|
|
||||||
|
|
||||||
class FillSlotSerializer(serializers.Serializer):
|
class FillSlotSerializer(serializers.Serializer):
|
||||||
|
datetime_formats = ['%Y-%m-%d %H:%M', 'iso-8601']
|
||||||
|
|
||||||
label = serializers.CharField(max_length=250, allow_blank=True)
|
label = serializers.CharField(max_length=250, allow_blank=True)
|
||||||
user_external_id = serializers.CharField(max_length=250, allow_blank=True)
|
user_external_id = serializers.CharField(max_length=250, allow_blank=True)
|
||||||
user_name = serializers.CharField(max_length=250, allow_blank=True) # compatibility
|
user_name = serializers.CharField(max_length=250, allow_blank=True) # compatibility
|
||||||
|
@ -95,6 +97,19 @@ class FillSlotSerializer(serializers.Serializer):
|
||||||
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
|
required=False, child=serializers.CharField(max_length=16, allow_blank=False)
|
||||||
)
|
)
|
||||||
check_overlaps = serializers.BooleanField(default=False)
|
check_overlaps = serializers.BooleanField(default=False)
|
||||||
|
start = serializers.DateTimeField(required=False, input_formats=datetime_formats)
|
||||||
|
end = serializers.DateTimeField(required=False, input_formats=datetime_formats)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
super().validate(attrs)
|
||||||
|
if attrs.get('start') or attrs.get('end'):
|
||||||
|
if not attrs.get('start'):
|
||||||
|
raise ValidationError(_('must include start when using end'))
|
||||||
|
if not attrs.get('end'):
|
||||||
|
raise ValidationError(_('must include end when using start'))
|
||||||
|
if attrs['start'] > attrs['end']:
|
||||||
|
raise ValidationError(_('start must be before end'))
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class SlotsSerializer(serializers.Serializer):
|
class SlotsSerializer(serializers.Serializer):
|
||||||
|
|
|
@ -581,6 +581,14 @@ def get_event_detail(
|
||||||
details['status'] = 'free'
|
details['status'] = 'free'
|
||||||
if hasattr(event, 'overlaps'):
|
if hasattr(event, 'overlaps'):
|
||||||
details['overlaps'] = event.overlaps
|
details['overlaps'] = event.overlaps
|
||||||
|
if agenda.partial_bookings:
|
||||||
|
details['full_intervals'] = [
|
||||||
|
(format_response_datetime(x.start_datetime), format_response_datetime(x.end_datetime))
|
||||||
|
for x in event.booking_set.filter(
|
||||||
|
cancellation_datetime__isnull=True,
|
||||||
|
in_waiting_list=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
@ -748,6 +756,8 @@ def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_li
|
||||||
user_display_label=payload.get('user_display_label', ''),
|
user_display_label=payload.get('user_display_label', ''),
|
||||||
extra_emails=payload.get('extra_emails', []),
|
extra_emails=payload.get('extra_emails', []),
|
||||||
extra_phone_numbers=payload.get('extra_phone_numbers', []),
|
extra_phone_numbers=payload.get('extra_phone_numbers', []),
|
||||||
|
start_datetime=payload.get('start'),
|
||||||
|
end_datetime=payload.get('end'),
|
||||||
extra_data=extra_data,
|
extra_data=extra_data,
|
||||||
color=color,
|
color=color,
|
||||||
)
|
)
|
||||||
|
@ -1416,6 +1426,9 @@ class Fillslots(APIView):
|
||||||
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
|
||||||
payload = serializer.validated_data
|
payload = serializer.validated_data
|
||||||
|
|
||||||
|
if agenda.partial_bookings and not 'start' in payload:
|
||||||
|
raise APIErrorBadRequest(N_('missing start/end'))
|
||||||
|
|
||||||
if 'slots' in payload:
|
if 'slots' in payload:
|
||||||
slots = payload['slots']
|
slots = payload['slots']
|
||||||
|
|
||||||
|
@ -1614,8 +1627,16 @@ class Fillslots(APIView):
|
||||||
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
|
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
|
||||||
raise APIError(N_('sold out'))
|
raise APIError(N_('sold out'))
|
||||||
else:
|
else:
|
||||||
if (event.booked_places + places_count) > event.places:
|
if not agenda.partial_bookings:
|
||||||
raise APIError(N_('sold out'))
|
if (event.booked_places + places_count) > event.places:
|
||||||
|
raise APIError(N_('sold out'))
|
||||||
|
else:
|
||||||
|
overlapping_booking_qs = event.booking_set.extra(
|
||||||
|
where=["(start_datetime, end_datetime) OVERLAPS (%s, %s)"],
|
||||||
|
params=[payload['start'], payload['end']],
|
||||||
|
)
|
||||||
|
if overlapping_booking_qs.exists():
|
||||||
|
raise APIError(N_('booking range not available'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
|
|
@ -103,6 +103,7 @@ class AgendaEditForm(forms.ModelForm):
|
||||||
'default_view',
|
'default_view',
|
||||||
'booking_form_url',
|
'booking_form_url',
|
||||||
'events_type',
|
'events_type',
|
||||||
|
'partial_bookings',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -110,12 +111,15 @@ class AgendaEditForm(forms.ModelForm):
|
||||||
if kwargs['instance'].kind != 'events':
|
if kwargs['instance'].kind != 'events':
|
||||||
del self.fields['booking_form_url']
|
del self.fields['booking_form_url']
|
||||||
del self.fields['events_type']
|
del self.fields['events_type']
|
||||||
|
del self.fields['partial_bookings']
|
||||||
self.fields['default_view'].choices = [
|
self.fields['default_view'].choices = [
|
||||||
(k, v) for k, v in self.fields['default_view'].choices if k != 'open_events'
|
(k, v) for k, v in self.fields['default_view'].choices if k != 'open_events'
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
if not EventsType.objects.exists():
|
if not EventsType.objects.exists():
|
||||||
del self.fields['events_type']
|
del self.fields['events_type']
|
||||||
|
if Booking.objects.filter(event__agenda=kwargs['instance']).exists():
|
||||||
|
del self.fields['partial_bookings']
|
||||||
|
|
||||||
|
|
||||||
class AgendaBookingDelaysForm(forms.ModelForm):
|
class AgendaBookingDelaysForm(forms.ModelForm):
|
||||||
|
|
|
@ -1370,6 +1370,8 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
|
||||||
def get_template_names(self):
|
def get_template_names(self):
|
||||||
if self.agenda.kind == 'virtual':
|
if self.agenda.kind == 'virtual':
|
||||||
return ['chrono/manager_meetings_agenda_day_view.html']
|
return ['chrono/manager_meetings_agenda_day_view.html']
|
||||||
|
if self.agenda.partial_bookings:
|
||||||
|
return ['chrono/manager_partial_bookings_day_view.html']
|
||||||
return ['chrono/manager_%s_agenda_day_view.html' % self.agenda.kind]
|
return ['chrono/manager_%s_agenda_day_view.html' % self.agenda.kind]
|
||||||
|
|
||||||
def get_previous_day_url(self):
|
def get_previous_day_url(self):
|
||||||
|
@ -1487,6 +1489,30 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
|
||||||
current_date += interval
|
current_date += interval
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
if self.agenda.partial_bookings:
|
||||||
|
self.fill_partial_bookings_context(context)
|
||||||
|
return context
|
||||||
|
|
||||||
|
def fill_partial_bookings_context(self, context):
|
||||||
|
events = self.agenda.event_set.filter(start_datetime__date=self.date.date())
|
||||||
|
# TODO avoir une heure en plus/moins
|
||||||
|
min_time = min(x.start_datetime for x in events)
|
||||||
|
max_time = max(x.start_datetime + datetime.timedelta(minutes=x.duration) for x in events)
|
||||||
|
context['hours'] = [
|
||||||
|
min_time + datetime.timedelta(hours=i) for i in range(max_time.hour - min_time.hour)
|
||||||
|
]
|
||||||
|
bookings = Booking.objects.filter(event__in=events)
|
||||||
|
bookings_by_user = collections.defaultdict(list)
|
||||||
|
|
||||||
|
for booking in bookings:
|
||||||
|
bookings_by_user[booking.user_external_id].append(booking)
|
||||||
|
booking.css_left = 100 * (booking.start_datetime - min_time).seconds // 3600
|
||||||
|
booking.css_width = 100 * (booking.end_datetime - booking.start_datetime).seconds // 3600
|
||||||
|
|
||||||
|
context['bookings_by_user'] = dict(bookings_by_user)
|
||||||
|
|
||||||
|
|
||||||
agenda_day_view = AgendaDayView.as_view()
|
agenda_day_view = AgendaDayView.as_view()
|
||||||
|
|
||||||
|
|
|
@ -1555,3 +1555,28 @@ def test_events_datetimes_max_booking_datetime_with_minimal_booking_time_to_none
|
||||||
resp = app.get(api_url)
|
resp = app.get(api_url)
|
||||||
assert resp.json['data'][-2]['datetime'] == '2023-04-05 09:00:00'
|
assert resp.json['data'][-2]['datetime'] == '2023-04-05 09:00:00'
|
||||||
assert resp.json['data'][-1]['datetime'] == '2023-04-05 11:00:00'
|
assert resp.json['data'][-1]['datetime'] == '2023-04-05 11:00:00'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.freeze_time('2023-03-10 14:00')
|
||||||
|
def test_datetimes_api_partial_bookings(app):
|
||||||
|
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
|
||||||
|
event_start = now() + datetime.timedelta(days=7)
|
||||||
|
event = Event.objects.create(start_datetime=event_start, duration=120, places=1, agenda=agenda)
|
||||||
|
|
||||||
|
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
|
||||||
|
assert resp.json['data'][0]['full_intervals'] == []
|
||||||
|
|
||||||
|
Booking.objects.create(
|
||||||
|
event=event, start_datetime=event_start, end_datetime=event_start + datetime.timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
Booking.objects.create(
|
||||||
|
event=event,
|
||||||
|
start_datetime=event_start + datetime.timedelta(minutes=45),
|
||||||
|
end_datetime=event_start + datetime.timedelta(minutes=60),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
|
||||||
|
assert resp.json['data'][0]['full_intervals'] == [
|
||||||
|
['2023-03-17 15:00:00', '2023-03-17 15:30:00'],
|
||||||
|
['2023-03-17 15:45:00', '2023-03-17 16:00:00'],
|
||||||
|
]
|
||||||
|
|
|
@ -2621,3 +2621,67 @@ def test_user_external_id(app, user):
|
||||||
assert not any(x['disabled'] for x in resp.json['data'])
|
assert not any(x['disabled'] for x in resp.json['data'])
|
||||||
|
|
||||||
meeting_event.delete()
|
meeting_event.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.freeze_time('2021-02-23 14:00')
|
||||||
|
def test_booking_api_partial_booking(app, user, settings):
|
||||||
|
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
|
||||||
|
event_start = now() + datetime.timedelta(days=5)
|
||||||
|
event = Event.objects.create(
|
||||||
|
label='Event', start_datetime=event_start, duration=120, places=1, agenda=agenda
|
||||||
|
)
|
||||||
|
|
||||||
|
app.authorization = ('Basic', ('john.doe', 'password'))
|
||||||
|
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||||
|
params={'start': event_start, 'end': event_start + datetime.timedelta(minutes=30)},
|
||||||
|
)
|
||||||
|
booking = Booking.objects.get()
|
||||||
|
assert booking.start_datetime == event_start
|
||||||
|
assert booking.end_datetime == event_start + datetime.timedelta(minutes=30)
|
||||||
|
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||||
|
params={
|
||||||
|
'start': event_start + datetime.timedelta(minutes=15),
|
||||||
|
'end': event_start + datetime.timedelta(minutes=45),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.json['err'] == 1
|
||||||
|
assert resp.json['err_desc'] == 'booking range not available'
|
||||||
|
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||||
|
params={
|
||||||
|
'start': event_start + datetime.timedelta(minutes=30),
|
||||||
|
'end': event_start + datetime.timedelta(minutes=45),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
booking = Booking.objects.get(pk=resp.json['booking_id'])
|
||||||
|
assert booking.start_datetime == event_start + datetime.timedelta(minutes=30)
|
||||||
|
assert booking.end_datetime == event_start + datetime.timedelta(minutes=45)
|
||||||
|
|
||||||
|
# missing start/end
|
||||||
|
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), status=400)
|
||||||
|
assert resp.json['err_desc'] == 'missing start/end'
|
||||||
|
|
||||||
|
# missing end
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), params={'start': event_start}, status=400
|
||||||
|
)
|
||||||
|
assert resp.json['errors']['non_field_errors'][0] == 'must include end when using start'
|
||||||
|
|
||||||
|
# missing start
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), params={'end': event_start}, status=400
|
||||||
|
)
|
||||||
|
assert resp.json['errors']['non_field_errors'][0] == 'must include start when using end'
|
||||||
|
|
||||||
|
# end before start
|
||||||
|
resp = app.post(
|
||||||
|
'/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id),
|
||||||
|
params={'start': event_start, 'end': event_start - datetime.timedelta(minutes=10)},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
assert resp.json['errors']['non_field_errors'][0] == 'start must be before end'
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db import connection
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.test.utils import CaptureQueriesContext
|
||||||
|
|
||||||
|
from chrono.agendas.models import (
|
||||||
|
Agenda,
|
||||||
|
Booking,
|
||||||
|
Desk,
|
||||||
|
Event,
|
||||||
|
MeetingType,
|
||||||
|
Resource,
|
||||||
|
TimePeriod,
|
||||||
|
TimePeriodException,
|
||||||
|
TimePeriodExceptionSource,
|
||||||
|
UnavailabilityCalendar,
|
||||||
|
)
|
||||||
|
from chrono.utils.timezone import now
|
||||||
|
from tests.utils import login
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_agenda_partial_bookings(app, admin_user):
|
||||||
|
agenda = Agenda.objects.create(label='Foo bar', kind='events')
|
||||||
|
event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
|
||||||
|
Booking.objects.create(event=event)
|
||||||
|
|
||||||
|
app = login(app)
|
||||||
|
resp = app.get('/manage/agendas/%s/edit' % agenda.pk)
|
||||||
|
assert 'partial_bookings' not in resp.form.fields
|
||||||
|
|
||||||
|
Event.objects.all().delete()
|
||||||
|
resp = app.get('/manage/agendas/%s/edit' % agenda.pk)
|
||||||
|
resp.form['partial_bookings'] = True
|
||||||
|
resp = resp.form.submit().follow()
|
||||||
|
|
||||||
|
resp = resp.click('New Event')
|
||||||
|
assert 'places' not in resp.form.fields
|
Loading…
Reference in New Issue