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