agendas: allow multiple checks by booking (#80371)

This commit is contained in:
Valentin Deniaud 2023-10-05 13:44:50 +02:00
parent 3cb80d478a
commit 81e93dd4c5
11 changed files with 123 additions and 85 deletions

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.21 on 2023-10-05 11:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0163_remove_booking_check_fields'),
]
operations = [
migrations.AlterField(
model_name='bookingcheck',
name='booking',
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name='user_checks', to='agendas.booking'
),
),
]

View File

@ -2141,7 +2141,7 @@ class Event(models.Model):
booking_qs = self.booking_set.filter(
cancellation_datetime__isnull=True,
in_waiting_list=False,
user_check__isnull=True,
user_checks__isnull=True,
primary_booking__isnull=True,
)
if booking_qs.exists():
@ -2165,7 +2165,7 @@ class Event(models.Model):
self.notify_checked()
def notify_checked(self):
for booking in self.booking_set.filter(user_check__isnull=False).select_related('user_check'):
for booking in self.booking_set.filter(user_checks__isnull=False).prefetch_related('user_checks'):
if booking.user_check.presence is True and booking.presence_callback_url:
url = booking.presence_callback_url
elif booking.user_check.presence is False and booking.absence_callback_url:
@ -2278,7 +2278,7 @@ class Event(models.Model):
'booking',
filter=Q(
booking__cancellation_datetime__isnull=True,
booking__user_check__presence=False,
booking__user_checks__presence=False,
booking__user_external_id=user_external_id,
),
),
@ -2398,13 +2398,13 @@ class Event(models.Model):
.values('event')
)
present_count = (
bookings.filter(user_check__presence=True).annotate(count=Count('event')).values('count')
bookings.filter(user_checks__presence=True).annotate(count=Count('event')).values('count')
)
absent_count = (
bookings.filter(user_check__presence=False).annotate(count=Count('event')).values('count')
bookings.filter(user_checks__presence=False).annotate(count=Count('event')).values('count')
)
notchecked_count = (
bookings.filter(user_check__isnull=True).annotate(count=Count('event')).values('count')
bookings.filter(user_checks__isnull=True).annotate(count=Count('event')).values('count')
)
return qs.annotate(
present_count=Coalesce(Subquery(present_count, output_field=IntegerField()), Value(0)),
@ -2819,6 +2819,15 @@ class Booking(models.Model):
def user_name(self):
return ('%s %s' % (self.user_first_name, self.user_last_name)).strip()
@cached_property
def user_check(self): # pylint: disable=method-hidden
user_checks = list(self.user_checks.all())
if len(user_checks) > 1:
raise AttributeError('booking has multiple checks')
return user_checks[0] if user_checks else None
@cached_property
def emails(self):
emails = set(self.extra_emails)
@ -2833,6 +2842,11 @@ class Booking(models.Model):
phone_numbers.add(self.user_phone_number)
return list(phone_numbers)
def refresh_from_db(self, *args, **kwargs):
if hasattr(self, 'user_check'):
del self.user_check
return super().refresh_from_db(*args, **kwargs)
def cancel(self, trigger_callback=False):
timestamp = now()
with transaction.atomic():
@ -2857,14 +2871,14 @@ class Booking(models.Model):
def reset_user_was_present(self):
with transaction.atomic():
if hasattr(self, 'user_check'):
if self.user_check:
self.user_check.delete()
self.user_check = None
self.event.checked = False
self.event.save(update_fields=['checked'])
def mark_user_absence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not hasattr(self, 'user_check'):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = False
self.user_check.type_slug = check_type_slug
@ -2880,7 +2894,7 @@ class Booking(models.Model):
self.event.set_is_checked()
def mark_user_presence(self, check_type_slug=None, check_type_label=None, start_time=None, end_time=None):
if not hasattr(self, 'user_check'):
if not self.user_check:
self.user_check = BookingCheck(booking=self)
self.user_check.presence = True
self.user_check.type_slug = check_type_slug
@ -2988,7 +3002,7 @@ class Booking(models.Model):
class BookingCheck(models.Model):
booking = models.OneToOneField(Booking, on_delete=models.CASCADE, related_name='user_check')
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='user_checks')
presence = models.BooleanField()

View File

@ -331,9 +331,7 @@ class BookingSerializer(serializers.ModelSerializer):
ret.pop('user_absence_reason', None)
ret.pop('user_presence_reason', None)
else:
user_was_present = (
self.instance.user_check.presence if hasattr(self.instance, 'user_check') else None
)
user_was_present = self.instance.user_check.presence if self.instance.user_check else None
ret['user_was_present'] = user_was_present
ret['user_absence_reason'] = (
self.instance.user_check.type_slug if user_was_present is False else None

View File

@ -2204,7 +2204,7 @@ class MultipleAgendasEventsCheckStatus(APIView):
booking_queryset = Booking.objects.filter(
event__in=events,
user_external_id=user_external_id,
).select_related('user_check')
).prefetch_related('user_checks')
bookings_by_event_id = collections.defaultdict(list)
for booking in booking_queryset:
bookings_by_event_id[booking.event_id].append(booking)
@ -2225,7 +2225,7 @@ class MultipleAgendasEventsCheckStatus(APIView):
booking.event = event # prevent db calls
if booking.cancellation_datetime is not None:
check_status = {'status': 'cancelled'}
elif not hasattr(booking, 'user_check'):
elif not booking.user_check:
check_status = {'status': 'error', 'error_reason': 'booking-not-checked'}
else:
check_status = {
@ -2519,18 +2519,18 @@ class BookingFilter(filters.FilterSet):
return queryset.filter(Q(event__slug=value) | Q(event__primary_event__slug=value))
def filter_user_was_present(self, queryset, name, value):
return queryset.filter(user_check__presence=value)
return queryset.filter(user_checks__presence=value)
def filter_user_absence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check__type_slug=value) | Q(user_check__type_label=value),
user_check__presence=False,
Q(user_checks__type_slug=value) | Q(user_checks__type_label=value),
user_checks__presence=False,
)
def filter_user_presence_reason(self, queryset, name, value):
return queryset.filter(
Q(user_check__type_slug=value) | Q(user_check__type_label=value),
user_check__presence=True,
Q(user_checks__type_slug=value) | Q(user_checks__type_label=value),
user_checks__presence=True,
)
class Meta:
@ -2574,7 +2574,8 @@ class BookingsAPI(ListAPIView):
def get_queryset(self):
return (
Booking.objects.filter(primary_booking__isnull=True, cancellation_datetime__isnull=True)
.select_related('event', 'event__agenda', 'event__desk', 'user_check')
.select_related('event', 'event__agenda', 'event__desk')
.prefetch_related('user_checks')
.order_by('event__start_datetime', 'event__slug', 'event__agenda__slug', 'pk')
)
@ -2664,7 +2665,7 @@ class BookingAPI(APIView):
user_was_present = serializer.validated_data.get(
'user_was_present',
self.booking.user_check.presence if hasattr(self.booking, 'user_check') else None,
self.booking.user_check.presence if self.booking.user_check else None,
)
if user_was_present is True and 'user_absence_reason' in request.data:
raise APIErrorBadRequest(N_('user is marked as present, can not set absence reason'), err=6)
@ -2679,16 +2680,16 @@ class BookingAPI(APIView):
self.booking.save()
if user_was_present is None:
if hasattr(self.booking, 'user_check'):
if self.booking.user_check:
self.booking.user_check.delete()
self.booking.user_check = None
else:
if not hasattr(self.booking, 'user_check'):
if not self.booking.user_check:
self.booking.user_check = BookingCheck(booking=self.booking)
self.booking.user_check.presence = user_was_present
self.booking.user_check.save()
if 'user_check_type_slug' in serializer.validated_data and hasattr(self.booking, 'user_check'):
if 'user_check_type_slug' in serializer.validated_data and self.booking.user_check:
self.booking.user_check.type_slug = serializer.validated_data['user_check_type_slug']
self.booking.user_check.type_label = serializer.validated_data['user_check_type_label']
self.booking.user_check.save()
@ -3261,8 +3262,8 @@ class BookingsStatistics(APIView):
if 'user_was_present' in group_by:
bookings = bookings.annotate(
presence=Case(
When(primary_booking__isnull=True, then=F('user_check__presence')),
When(primary_booking__isnull=False, then=F('primary_booking__user_check__presence')),
When(primary_booking__isnull=True, then=F('user_checks__presence')),
When(primary_booking__isnull=False, then=F('primary_booking__user_checks__presence')),
)
)
lookups.append('presence')

View File

@ -531,15 +531,15 @@ class BookingCheckFilterSet(django_filters.FilterSet):
if value == 'booked':
return queryset
if value == 'not-checked':
return queryset.filter(user_check__isnull=True)
return queryset.filter(user_checks__isnull=True)
if value == 'presence':
return queryset.filter(user_check__presence=True)
return queryset.filter(user_checks__presence=True)
if value == 'absence':
return queryset.filter(user_check__presence=False)
return queryset.filter(user_checks__presence=False)
if value.startswith('absence::'):
return queryset.filter(user_check__presence=False, user_check__type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=False, user_checks__type_slug=value.split('::')[1])
if value.startswith('presence::'):
return queryset.filter(user_check__presence=True, user_check__type_slug=value.split('::')[1])
return queryset.filter(user_checks__presence=True, user_checks__type_slug=value.split('::')[1])
return queryset
def do_nothing(self, queryset, name, value):

View File

@ -1399,7 +1399,7 @@ class EventChecksMixin:
booking_qs_kwargs = {}
if not self.agenda.subscriptions.exists():
booking_qs_kwargs = {'cancellation_datetime__isnull': True}
booking_qs = event.booking_set.select_related('user_check').order_by('start_time')
booking_qs = event.booking_set.prefetch_related('user_checks').order_by('start_time')
booked_qs = booking_qs.filter(
in_waiting_list=False, primary_booking__isnull=True, **booking_qs_kwargs
)
@ -1441,19 +1441,15 @@ class EventChecksMixin:
results = []
booked_without_status = False
for booking in booked_filterset.qs:
if booking.cancellation_datetime is None and not hasattr(booking, 'user_check'):
if booking.cancellation_datetime is None and not booking.user_check:
booked_without_status = True
booking.absence_form = BookingCheckAbsenceForm(
agenda=self.agenda,
initial={
'check_type': booking.user_check.type_slug if hasattr(booking, 'user_check') else None
},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.presence_form = BookingCheckPresenceForm(
agenda=self.agenda,
initial={
'check_type': booking.user_check.type_slug if hasattr(booking, 'user_check') else None
},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.kind = 'booking'
results.append(booking)
@ -1656,7 +1652,7 @@ class AgendaDayView(EventChecksMixin, AgendaDateView, DayArchiveView):
booking.css_left = get_time_ratio(booking.start_time, start_time)
booking.css_width = get_time_ratio(booking.end_time, booking.start_time)
if hasattr(booking, 'user_check'):
if booking.user_check:
booking.check_css_class = 'present' if booking.user_check.presence else 'absent'
booking.check_css_left = get_time_ratio(booking.user_check.start_time, start_time)
booking.check_css_width = get_time_ratio(
@ -1922,7 +1918,7 @@ class AgendaWeekMonthMixin:
]
booking_info_by_user = {}
bookings = Booking.objects.filter(event__in=self.events).select_related('user_check')
bookings = Booking.objects.filter(event__in=self.events).prefetch_related('user_checks')
for booking in bookings:
booking_info = booking_info_by_user.setdefault(
booking.user_external_id,
@ -1935,7 +1931,7 @@ class AgendaWeekMonthMixin:
)
user_bookings = booking_info['bookings']
if hasattr(booking, 'user_check'):
if booking.user_check:
booking.check_css_class = 'present' if booking.user_check.presence else 'absent'
user_bookings[localtime(booking.event.start_datetime).day - 1] = booking
@ -2748,19 +2744,15 @@ class EventDetailView(ViewableAgendaMixin, DetailView):
event = self.object
context['booked'] = (
event.booking_set.filter(cancellation_datetime__isnull=True, in_waiting_list=False)
.select_related('user_check')
.prefetch_related('user_checks')
.order_by('creation_datetime')
)
context['waiting'] = event.booking_set.filter(
cancellation_datetime__isnull=True, in_waiting_list=True
).order_by('creation_datetime')
event.present_count = len(
[b for b in context['booked'] if hasattr(b, 'user_check') and b.user_check.presence]
)
event.absent_count = len(
[b for b in context['booked'] if hasattr(b, 'user_check') and not b.user_check.presence]
)
event.notchecked_count = len([b for b in context['booked'] if not hasattr(b, 'user_check')])
event.present_count = len([b for b in context['booked'] if b.user_check and b.user_check.presence])
event.absent_count = len([b for b in context['booked'] if b.user_check and not b.user_check.presence])
event.notchecked_count = len([b for b in context['booked'] if not b.user_check])
return context
@ -2899,7 +2891,7 @@ class EventCheckMixin:
event__cancelled=False,
cancellation_datetime__isnull=True,
in_waiting_list=False,
user_check__isnull=True,
user_checks__isnull=True,
)
def get_check_type(self, kind):
@ -2948,7 +2940,7 @@ class EventPresenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
bookings = self.get_bookings()
booking_checks_to_create = []
for booking in bookings:
if hasattr(booking, 'user_check'):
if booking.user_check:
continue
booking_check = BookingCheck(booking=booking, presence=True, **qs_kwargs)
@ -2979,7 +2971,7 @@ class EventAbsenceView(EventCheckMixin, ViewableAgendaMixin, FormView):
bookings = self.get_bookings()
booking_checks_to_create = []
for booking in bookings:
if hasattr(booking, 'user_check'):
if booking.user_check:
continue
booking_check = BookingCheck(booking=booking, presence=False, **qs_kwargs)
@ -3767,15 +3759,11 @@ class BookingCheckMixin:
if is_ajax(request):
booking.absence_form = BookingCheckAbsenceForm(
agenda=self.agenda,
initial={
'check_type': booking.user_check.type_slug if hasattr(booking, 'user_check') else None
},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.presence_form = BookingCheckPresenceForm(
agenda=self.agenda,
initial={
'check_type': booking.user_check.type_slug if hasattr(booking, 'user_check') else None
},
initial={'check_type': booking.user_check.type_slug if booking.user_check else None},
)
booking.kind = 'booking'
return render(
@ -4531,7 +4519,7 @@ class PartialBookingCheckMixin(ViewableAgendaMixin):
def get_object(self):
booking = self.get_booking(**self.kwargs)
return getattr(booking, 'user_check', BookingCheck(booking=booking))
return booking.user_check or BookingCheck(booking=booking)
def get_success_url(self):
date = self.object.booking.event.start_datetime

View File

@ -186,7 +186,7 @@ def test_bookings_api(app, user):
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/bookings/', params={'user_external_id': 'enfant-1234'})
assert len(ctx.captured_queries) == 2
assert len(ctx.captured_queries) == 3
assert resp.json['err'] == 0
assert resp.json['data'] == [
@ -616,7 +616,7 @@ def test_booking_patch_api_present(app, user, flag):
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not hasattr(booking, 'user_check')
assert not booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -628,14 +628,14 @@ def test_booking_patch_api_present(app, user, flag):
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not hasattr(booking, 'user_check')
assert not booking.user_check
event.refresh_from_db()
assert event.checked == (flag is not None)
# reset
app.patch_json('/api/booking/%s/' % booking.pk, params={'user_was_present': None})
booking.refresh_from_db()
assert not hasattr(booking, 'user_check')
assert not booking.user_check
# make secondary bookings
Booking.objects.create(event=event, primary_booking=booking)
@ -648,11 +648,11 @@ def test_booking_patch_api_present(app, user, flag):
if flag is not None:
assert booking.user_check.presence == flag
else:
assert not hasattr(booking, 'user_check')
assert not booking.user_check
# secondary bookings are left untouched
assert list(booking.secondary_booking_set.values_list('user_check', flat=True)) == [None, None]
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert not hasattr(other_booking, 'user_check') # not changed
assert not other_booking.user_check # not changed
# mark the event as checked
event.checked = True
@ -741,9 +741,9 @@ def test_booking_patch_api_absence_reason(check_types, app, user):
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# secondary bookings are left unchanged
assert list(booking.secondary_booking_set.values_list('user_check', flat=True)) == [None, None]
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert not hasattr(other_booking, 'user_check') # not changed
assert not other_booking.user_check # not changed
# presence is True, can not set user_absence_reason
BookingCheck.objects.update(presence=True)
@ -855,9 +855,9 @@ def test_booking_patch_api_presence_reason(check_types, app, user):
assert booking.user_check.type_slug == 'foo-bar'
assert booking.user_check.type_label == 'Foo bar'
# secondary bookings are left unchanged
assert list(booking.secondary_booking_set.values_list('user_check', flat=True)) == [None, None]
assert list(booking.secondary_booking_set.values_list('user_checks', flat=True)) == [None, None]
other_booking.refresh_from_db()
assert not hasattr(other_booking, 'user_check') # not changed
assert not other_booking.user_check # not changed
# presence is False, can not set user_presence_reason
BookingCheck.objects.update(presence=False)

View File

@ -1415,7 +1415,7 @@ def test_events_check_status_events(app, user, partial_bookings):
}
with CaptureQueriesContext(connection) as ctx:
resp = app.get(url, params=params)
assert len(ctx.captured_queries) == 5
assert len(ctx.captured_queries) == 6
assert resp.json['err'] == 0
assert len(resp.json['data']) == 3
assert list(resp.json['data'][0].keys()) == ['event', 'check_status', 'booking']

View File

@ -1807,7 +1807,7 @@ def test_event_checked(app, admin_user):
assert 'Mark the event as checked' not in resp
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'checked tag' in resp
for booking in Booking.objects.filter(user_check__isnull=True):
for booking in Booking.objects.filter(user_checks__isnull=True):
booking.mark_user_presence()
for url in urls:
resp = app.get(url)
@ -2087,7 +2087,7 @@ def test_event_check_filters(check_types, app, admin_user):
resp = app.get(
'/manage/agendas/%s/events/%s/check' % (agenda.pk, event.pk), params={'extra-data-foo': 'val1'}
)
assert len(ctx.captured_queries) == 10
assert len(ctx.captured_queries) == 11
assert 'User none' not in resp
assert 'User empty' not in resp
assert 'User foo-val1 bar-none presence' in resp
@ -2363,9 +2363,9 @@ def test_event_check_booking(check_types, app, admin_user):
assert '/manage/agendas/%s/bookings/%s/absence' % (agenda.pk, booking.pk) in resp
assert '/manage/agendas/%s/bookings/%s/reset' % (agenda.pk, booking.pk) not in resp
booking.refresh_from_db()
assert not hasattr(booking, 'user_check')
assert not booking.user_check
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -2394,7 +2394,7 @@ def test_event_check_booking(check_types, app, admin_user):
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is False
@ -2417,7 +2417,7 @@ def test_event_check_booking(check_types, app, admin_user):
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2442,7 +2442,7 @@ def test_event_check_booking(check_types, app, admin_user):
assert booking.user_check.type_slug == 'foo-reason'
assert booking.user_check.type_label == 'Foo reason'
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2462,7 +2462,7 @@ def test_event_check_booking(check_types, app, admin_user):
assert booking.user_check.type_slug is None
assert booking.user_check.type_label is None
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2492,7 +2492,7 @@ def test_event_check_booking(check_types, app, admin_user):
assert booking.user_check.type_slug == 'bar-reason'
assert booking.user_check.type_label == 'Bar reason'
secondary_booking.refresh_from_db()
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
event.refresh_from_db()
assert event.checked is True
@ -2671,7 +2671,7 @@ def test_event_check_cancelled_booking(check_types, app, admin_user):
assert booking.user_check.presence is True
secondary_booking.refresh_from_db()
assert secondary_booking.cancellation_datetime is None
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
booking.cancel()
resp = app.post(
@ -2686,7 +2686,7 @@ def test_event_check_cancelled_booking(check_types, app, admin_user):
assert booking.user_check.presence is False
secondary_booking.refresh_from_db()
assert secondary_booking.cancellation_datetime is None
assert not hasattr(secondary_booking, 'user_check')
assert not secondary_booking.user_check
@mock.patch('chrono.manager.forms.get_agenda_check_types')

View File

@ -671,7 +671,7 @@ def test_manager_partial_bookings_event_checked(app, admin_user):
assert 'Mark the event as checked' not in resp
resp = app.get('/manage/agendas/%s/settings' % agenda.id)
assert 'checked tag' in resp
for booking in Booking.objects.filter(user_check__isnull=True):
for booking in Booking.objects.filter(user_checks__isnull=True):
booking.mark_user_presence(start_time=datetime.time(8, 00), end_time=datetime.time(10, 00))
resp = app.get('/manage/agendas/%s/day/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
assert '<span class="checked tag">Checked</span>' in resp

View File

@ -17,6 +17,7 @@ from chrono.agendas.models import (
AgendaNotificationsSettings,
AgendaReminderSettings,
Booking,
BookingCheck,
Category,
Desk,
Event,
@ -4222,3 +4223,19 @@ def test_event_refresh_booking_computed_times():
booking.cancellation_datetime = None
booking.save()
test_booking(True)
def test_agenda_booking_user_check_property():
agenda = Agenda.objects.create(label='Agenda', kind='events')
event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10)
booking = Booking.objects.create(event=event)
booking_check = BookingCheck.objects.create(booking=booking, presence=True)
assert booking.user_check == booking_check
BookingCheck.objects.create(booking=booking, presence=False)
booking.refresh_from_db()
with pytest.raises(AttributeError):
# pylint: disable=pointless-statement
booking.user_check