Compare commits

...

2 Commits

Author SHA1 Message Date
Valentin Deniaud 58d2eb8582 wip-manager 2023-05-24 18:21:51 +02:00
Valentin Deniaud 69e8d97245 partial bookings layout 2023-05-24 15:54:56 +02:00
9 changed files with 229 additions and 2 deletions

View File

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

View File

@ -284,6 +284,7 @@ class Agenda(models.Model):
null=True,
blank=True,
)
partial_bookings = models.BooleanField(_('Allow partial bookings'), default=False)
class Meta:
ordering = ['label']
@ -2207,6 +2208,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')
start_datetime = models.DateTimeField(null=True)
end_datetime = models.DateTimeField(null=True)
@property
def user_name(self):
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()

View File

@ -68,6 +68,8 @@ class AgendaSlugsMixin(metaclass=serializers.SerializerMetaclass):
class FillSlotSerializer(serializers.Serializer):
datetime_formats = ['%Y-%m-%d %H:%M', 'iso-8601']
label = 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
@ -95,6 +97,19 @@ class FillSlotSerializer(serializers.Serializer):
required=False, child=serializers.CharField(max_length=16, allow_blank=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):

View File

@ -581,6 +581,14 @@ def get_event_detail(
details['status'] = 'free'
if hasattr(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
@ -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', ''),
extra_emails=payload.get('extra_emails', []),
extra_phone_numbers=payload.get('extra_phone_numbers', []),
start_datetime=payload.get('start'),
end_datetime=payload.get('end'),
extra_data=extra_data,
color=color,
)
@ -1416,6 +1426,9 @@ class Fillslots(APIView):
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data
if agenda.partial_bookings and not 'start' in payload:
raise APIErrorBadRequest(N_('missing start/end'))
if 'slots' in payload:
slots = payload['slots']
@ -1614,8 +1627,16 @@ class Fillslots(APIView):
if (event.booked_waiting_list_places + places_count) > event.waiting_list_places:
raise APIError(N_('sold out'))
else:
if (event.booked_places + places_count) > event.places:
raise APIError(N_('sold out'))
if not agenda.partial_bookings:
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:
with transaction.atomic():

View File

@ -103,6 +103,7 @@ class AgendaEditForm(forms.ModelForm):
'default_view',
'booking_form_url',
'events_type',
'partial_bookings',
]
def __init__(self, *args, **kwargs):
@ -110,12 +111,15 @@ class AgendaEditForm(forms.ModelForm):
if kwargs['instance'].kind != 'events':
del self.fields['booking_form_url']
del self.fields['events_type']
del self.fields['partial_bookings']
self.fields['default_view'].choices = [
(k, v) for k, v in self.fields['default_view'].choices if k != 'open_events'
]
else:
if not EventsType.objects.exists():
del self.fields['events_type']
if Booking.objects.filter(event__agenda=kwargs['instance']).exists():
del self.fields['partial_bookings']
class AgendaBookingDelaysForm(forms.ModelForm):

View File

@ -1370,6 +1370,8 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
def get_template_names(self):
if self.agenda.kind == 'virtual':
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]
def get_previous_day_url(self):
@ -1487,6 +1489,30 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
current_date += interval
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()

View File

@ -1555,3 +1555,28 @@ def test_events_datetimes_max_booking_datetime_with_minimal_booking_time_to_none
resp = app.get(api_url)
assert resp.json['data'][-2]['datetime'] == '2023-04-05 09: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'],
]

View File

@ -2621,3 +2621,67 @@ def test_user_external_id(app, user):
assert not any(x['disabled'] for x in resp.json['data'])
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'

View File

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