plages libres #89

Merged
vdeniaud merged 5 commits from wip/78056-plages-libres-socle-de-base into main 2023-06-01 10:24:39 +02:00
13 changed files with 377 additions and 10 deletions

View File

@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2023-05-31 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0153_event_index'),
]
operations = [
migrations.AddField(
model_name='agenda',
name='partial_bookings',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='booking',
name='end_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='booking',
name='start_time',
field=models.TimeField(null=True),
),
migrations.AddField(
model_name='event',
name='end_time',
field=models.TimeField(null=True, verbose_name='End time'),
),
]

View File

@ -289,6 +289,7 @@ class Agenda(models.Model):
null=True,
blank=True,
)
partial_bookings = models.BooleanField(default=False)
class Meta:
ordering = ['label']
@ -1934,6 +1935,7 @@ class Event(models.Model):
agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE)
start_datetime = models.DateTimeField(_('Date/time'))
end_time = models.TimeField(_('End time'), null=True)
recurrence_days = ArrayField(
models.IntegerField(choices=WEEKDAY_CHOICES),
verbose_name=_('Recurrence days'),
@ -2289,6 +2291,11 @@ class Event(models.Model):
@property
def end_datetime(self):
if self.end_time:
return localtime(self.start_datetime).replace(
hour=self.end_time.hour, minute=self.end_time.minute
)
if self.meeting_type:
minutes = self.meeting_type.duration
else:
@ -2429,6 +2436,7 @@ class Event(models.Model):
agenda=self.agenda,
primary_event=self,
slug=self.slug,
end_time=self.end_time,
duration=self.duration,
places=self.places,
waiting_list_places=self.waiting_list_places,
@ -2665,6 +2673,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_time = models.TimeField(null=True)
end_time = models.TimeField(null=True)
@property
def user_name(self):
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()

View File

@ -188,6 +188,18 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsFillSlotsSerializer):
check_overlaps = CommaSeparatedStringField(
required=False, child=serializers.SlugField(max_length=160, allow_blank=False)
)
start_time = serializers.TimeField(required=False)
end_time = serializers.TimeField(required=False)
def validate(self, attrs):
super().validate(attrs)
use_partial_bookings = any(agenda.partial_bookings for agenda in self.context['agendas'])
if use_partial_bookings:
if not attrs.get('start_time') or not attrs.get('end_time'):
raise ValidationError(_('must include start_time and end_time for partial bookings agenda'))
if attrs['start_time'] > attrs['end_time']:
raise ValidationError(_('start_time must be before end_time'))
return attrs
def validate_slots(self, value):
super().validate_slots(value)
@ -401,6 +413,14 @@ class AgendaOrSubscribedSlugsMixin(AgendaSlugsMixin):
attrs['agenda_slugs'] = [agenda.slug for agenda in agendas]
else:
attrs['agenda_slugs'] = self.agenda_slugs
if any(
agenda.partial_bookings != attrs['agendas'][0].partial_bookings for agenda in attrs['agendas']
):
raise serializers.ValidationError(
{'agendas': _('Cannot mix partial bookings agendas with other kinds.')}
)
return attrs
def validate_agendas(self, value):

View File

@ -461,6 +461,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_time=payload.get('start_time'),
end_time=payload.get('end_time'),
extra_data=extra_data,
color=color,
)

View File

@ -79,10 +79,22 @@ class AgendaAddForm(forms.ModelForm):
edit_role = forms.ModelChoiceField(label=_('Edit Role'), required=False, queryset=get_role_queryset())
view_role = forms.ModelChoiceField(label=_('View Role'), required=False, queryset=get_role_queryset())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.PARTIAL_BOOKINGS_ENABLED:
self.fields['kind'].choices += [('partial-bookings', _('Partial bookings'))]
class Meta:
model = Agenda
fields = ['label', 'kind', 'category', 'edit_role', 'view_role']
def clean(self):
super().clean()
if self.cleaned_data.get('kind') == 'partial-bookings':
self.cleaned_data['kind'] = 'events'
self.instance.partial_bookings = True
self.instance.default_view = 'day'
def save(self, *args, **kwargs):
create = self.instance.pk is None
super().save()
@ -118,6 +130,8 @@ class AgendaEditForm(forms.ModelForm):
else:
if not EventsType.objects.exists():
del self.fields['events_type']
if kwargs['instance'].partial_bookings:
del self.fields['default_view']
class AgendaBookingDelaysForm(forms.ModelForm):
@ -187,6 +201,7 @@ class NewEventForm(forms.ModelForm):
fields = [
'label',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -199,14 +214,26 @@ class NewEventForm(forms.ModelForm):
}
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'end_time': widgets.TimeWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.agenda.partial_bookings:
del self.fields['duration']
else:
del self.fields['end_time']
def clean(self):
super().clean()
if self.cleaned_data.get('frequency') == 'unique':
self.cleaned_data['recurrence_days'] = None
self.cleaned_data['recurrence_end_date'] = None
end_time = self.cleaned_data.get('end_time')
if end_time and self.cleaned_data['start_datetime'].time() > end_time:
self.add_error('end_time', _('End time must be greater than start time.'))
def clean_start_datetime(self):
start_datetime = self.cleaned_data['start_datetime']
if start_datetime.year < 2000:
@ -242,6 +269,7 @@ class EventForm(NewEventForm):
protected_fields = (
'slug',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -251,11 +279,13 @@ class EventForm(NewEventForm):
model = Event
widgets = {
'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'end_time': widgets.TimeWidget,
}
fields = [
'label',
'slug',
'start_datetime',
'end_time',
'frequency',
'recurrence_days',
'recurrence_week_interval',
@ -284,6 +314,8 @@ class EventForm(NewEventForm):
)
if self.instance.recurrence_days and self.instance.has_recurrences_booked():
for field in self.protected_fields:
if field not in self.fields:
continue
self.fields[field].disabled = True
self.fields[field].help_text = _(
'This field cannot be modified because some recurrences have bookings attached to them.'
@ -1245,7 +1277,7 @@ class ImportEventsForm(forms.Form):
raise ValidationError(_('Invalid file format. (duration, {event_no} event)'), i)
try:
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event', 'end_time'])
except ValidationError as e:
errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items():

View File

@ -597,3 +597,10 @@ div#appbar a.active {
background: #386ede;
color: white;
}
table.partial-bookings {
border-spacing: 0;
td.hour-cell {
outline: solid 1px;
}
}

View File

@ -3,14 +3,16 @@
{% now "m" as today_month %}
{% now "j" as today_day %}
{% now "Ymj" as today %}
{% if not no_opened and agenda.kind == 'events' %}
<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
{% if not agenda.partial_bookings %}
{% if not no_opened and agenda.kind == 'events' %}
<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
{% endif %}
<span class="buttons-group">
<a {% if active == 'day' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Day' %}</a>
<a {% if active == 'week' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Week' %}</a>
<a {% if active == 'month' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-month-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Month' %}</a>
</span>
{% endif %}
<span class="buttons-group">
<a {% if active == 'day' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Day' %}</a>
<a {% if active == 'week' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Week' %}</a>
<a {% if active == 'month' %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-month-view' pk=agenda.pk year=view.date|date:"Y"|default:today_year|default:today_year month=view.date|date:"m"|default:today_month day=view.date|date:"d"|default:today_day %}">{% trans 'Month' %}</a>
</span>
{% if not no_today %}
<a {% if active == 'day' and view.date|date:"Ymj" == today %}class="active"{% endif%} href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=today_year month=today_month day=today_day %}">{% trans 'Today' %}</a>
{% endif %}

View File

@ -0,0 +1,43 @@
{% extends "chrono/manager_agenda_day_view.html" %}
{% load i18n %}
{% block content %}
{% if not hours %}
<div>
<p>{% trans "No opening hours this day." %}</p>
</div>
{% else %}
<table class="agenda-table partial-bookings">
<thead>
<tr>
<td></td>
{% for hour in hours %}
<th>{{ hour|date:"TIME_FORMAT" }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for user, bookings in bookings_by_user.items %}
<tr class="{% cycle 'odd' 'even' %}">
<th>{{ bookings.0.get_user_block }}</th>
{% for _ in hours %}
<td class="hour-cell">
{% if forloop.first %}
{% for booking in bookings %}
<div class="booking"
style="left: {{ booking.css_left }}%; width: {{ booking.css_width }}%;"
>{{ booking.start_time }} - {{ booking.end_time }}</div>
{% endfor %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

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,37 @@ 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())
if not events.exists():
return
event_times = events.aggregate(Min('start_datetime'), Max('end_time'))
min_time = localtime(event_times['start_datetime__min']).time()
max_time = event_times['end_time__max']
start_time = datetime.time(min_time.hour - 1, 0)
end_time = datetime.time(max_time.hour + 2, 0)
context['hours'] = [datetime.time(hour=i) for i in range(start_time.hour, end_time.hour)]
def get_time_ratio(t1, t2):
return 100 * ((t1.hour - t2.hour) * 60 + t1.minute - t2.minute) // 60
bookings = Booking.objects.filter(event__in=events)
bookings_by_user = collections.defaultdict(list)
for booking in bookings:
booking.css_left = get_time_ratio(booking.start_time, start_time)
booking.css_width = get_time_ratio(booking.end_time, booking.start_time)
bookings_by_user[booking.user_external_id].append(booking)
context['bookings_by_user'] = dict(bookings_by_user)
agenda_day_view = AgendaDayView.as_view()

View File

@ -199,6 +199,7 @@ REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
SHARED_CUSTODY_ENABLED = False
LEGACY_FILLSLOTS_ENABLED = False
PARTIAL_BOOKINGS_ENABLED = False
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')

View File

@ -1555,3 +1555,16 @@ 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-05-01')
def test_events_datetime_partial_bookings_end_datetime(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert resp.json['data'][0]['datetime'] == '2023-05-02 08:00:00'
assert resp.json['data'][0]['end_datetime'] == '2023-05-02 18:00:00'

View File

@ -16,7 +16,7 @@ from chrono.agendas.models import (
SharedCustodyRule,
Subscription,
)
from chrono.utils.timezone import now
from chrono.utils.timezone import make_aware, now
pytestmark = pytest.mark.django_db
@ -1609,7 +1609,7 @@ def test_recurring_events_api_fillslots_overlapping_events(app, user):
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_overlapping_events_partial_booking(app, user):
def test_recurring_events_api_fillslots_partly_overlapping_events(app, user):
agenda = Agenda.objects.create(label='First Agenda', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start, end = now(), now() + datetime.timedelta(days=30)
@ -1677,3 +1677,63 @@ def test_recurring_events_api_fillslots_overlapping_events_partial_booking(app,
'2021-09-28',
'2021-10-05',
]
@pytest.mark.freeze_time('2023-05-01 10:00')
def test_recurring_events_api_fillslots_partial_bookings(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start_datetime = make_aware(datetime.datetime(2023, 5, 2, 8, 0))
event = Event.objects.create(
label='Event 08-18',
start_datetime=start_datetime,
end_time=datetime.time(18, 00),
places=2,
recurrence_end_date=start_datetime + datetime.timedelta(days=30),
recurrence_days=[1],
agenda=agenda,
)
event.create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
params = {
'user_external_id': 'user_id',
'slots': 'foo-bar@event-08-18:1',
'start_time': '10:00',
'end_time': '15:00',
}
fillslots_url = '/api/agendas/recurring-events/fillslots/?action=update&agendas=%s' % agenda.slug
resp = app.post_json(fillslots_url, params=params)
assert Booking.objects.count() == 5
for booking in Booking.objects.all():
assert booking.start_time == datetime.time(10, 00)
assert booking.end_time == datetime.time(15, 00)
# mix with other kind
other_agenda = Agenda.objects.create(label='Not partial', kind='events')
resp = app.post_json(fillslots_url + ',%s' % other_agenda.slug, params=params, status=400)
assert resp.json['errors']['agendas'][0] == 'Cannot mix partial bookings agendas with other kinds.'
# missing start_time
del params['start_time']
resp = app.post_json(fillslots_url, params=params, status=400)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# missing end_time
params['start_time'] = '10:00'
del params['end_time']
resp = app.post_json(fillslots_url, params=params, status=400)
assert (
resp.json['errors']['non_field_errors'][0]
== 'must include start_time and end_time for partial bookings agenda'
)
# end before start
params['end_time'] = '09:00'
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['errors']['non_field_errors'][0] == 'start_time must be before end_time'

View File

@ -0,0 +1,111 @@
import datetime
import pytest
from chrono.agendas.models import Agenda, Booking, Event
from chrono.utils.timezone import make_aware
from tests.utils import login
pytestmark = pytest.mark.django_db
def test_manager_partial_bookings_add_agenda(app, admin_user, settings):
app = login(app)
resp = app.get('/manage/agendas/add/')
assert 'partial-bookings' not in resp.text
settings.PARTIAL_BOOKINGS_ENABLED = True
resp = app.get('/manage/agendas/add/')
resp.form['label'] = 'Foo bar'
resp.form['kind'] = 'partial-bookings'
resp = resp.form.submit().follow()
agenda = Agenda.objects.get(label='Foo bar')
assert agenda.kind == 'events'
assert agenda.partial_bookings is True
assert agenda.default_view == 'day'
resp = resp.click('Options')
assert 'default_view' not in resp.form.fields
def test_manager_partial_bookings_add_event(app, admin_user):
agenda = Agenda.objects.create(label='Foo bar', kind='events', partial_bookings=True)
app = login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
resp = resp.click('New Event')
assert 'duration' not in resp.form.fields
resp.form['start_datetime_0'] = '2023-02-15'
resp.form['start_datetime_1'] = '08:00'
resp.form['end_time'] = '18:00'
resp.form['places'] = 10
resp = resp.form.submit().follow()
event = Event.objects.get()
assert event.end_time == datetime.time(18, 00)
assert event.duration is None
resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.pk, event.pk))
assert 'duration' not in resp.form.fields
assert resp.form['end_time'].value == '18:00'
def test_manager_partial_bookings_day_view(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))
event = Event.objects.create(
label='Event', start_datetime=start_datetime, end_time=datetime.time(18, 00), places=10, agenda=agenda
)
Booking.objects.create(
user_external_id='xxx',
user_first_name='Jane',
user_last_name='Doe',
start_time=datetime.time(11, 00),
end_time=datetime.time(13, 30),
event=event,
)
Booking.objects.create(
user_external_id='yyy',
user_first_name='Jon',
user_last_name='Doe',
start_time=datetime.time(8, 00),
end_time=datetime.time(10, 00),
event=event,
)
Booking.objects.create(
user_external_id='yyy',
user_first_name='Jon',
user_last_name='Doe',
start_time=datetime.time(12, 00),
end_time=datetime.time(14, 00),
event=event,
)
app = login(app)
today = start_datetime.date()
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert 'Week' not in resp.text
assert 'Month' not in resp.text
# time range from one hour before event start to one hour after end
assert (
resp.pyquery('thead th').text()
== '7 a.m. 8 a.m. 9 a.m. 10 a.m. 11 a.m. noon 1 p.m. 2 p.m. 3 p.m. 4 p.m. 5 p.m. 6 p.m. 7 p.m.'
)
assert len(resp.pyquery('tbody tr')) == 2
assert resp.pyquery('tbody tr th')[0].text == 'Jane Doe'
assert resp.pyquery('tbody tr th')[1].text == 'Jon Doe'
assert resp.pyquery('tbody tr div.booking')[0].text == '11 a.m. - 1:30 p.m.'
assert resp.pyquery('tbody tr div.booking')[0].attrib['style'] == 'left: 400%; width: 250%;'
assert resp.pyquery('tbody tr div.booking')[1].text == '8 a.m. - 10 a.m.'
assert resp.pyquery('tbody tr div.booking')[1].attrib['style'] == 'left: 100%; width: 200%;'
assert resp.pyquery('tbody tr div.booking')[2].text == 'noon - 2 p.m.'
assert resp.pyquery('tbody tr div.booking')[2].attrib['style'] == 'left: 500%; width: 200%;'
resp = resp.click('Next day')
assert 'No opening hours this day.' in resp.text