import datetime import json import smtplib from unittest import mock import pytest import requests from django.contrib.auth.models import Group, User from django.core.files.base import ContentFile from django.core.management import call_command from django.db import IntegrityError, connection, transaction from django.db.models import Q from django.test import override_settings from django.test.utils import CaptureQueriesContext from chrono.agendas.models import ( Agenda, AgendaNotificationsSettings, AgendaReminderSettings, Booking, BookingCheck, Category, Desk, Event, EventCancellationReport, ICSError, MeetingType, Person, Resource, SharedCustodyAgenda, SharedCustodyHolidayRule, SharedCustodyPeriod, SharedCustodyRule, TimePeriod, TimePeriodException, TimePeriodExceptionGroup, TimePeriodExceptionSource, UnavailabilityCalendar, VirtualMember, ) from chrono.utils.interval import IntervalSet from chrono.utils.timezone import localtime, make_aware, make_naive, now from .utils import add_day_timeperiod, add_exception, add_meeting, build_agendas, paris, utc pytestmark = pytest.mark.django_db ICS_SAMPLE = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170824T082855Z DTSTART:20170831T170800Z DTEND:20170831T203400Z SEQUENCE:1 SUMMARY:Évènement 1 END:VEVENT BEGIN:VEVENT DTSTAMP:20170824T092855Z DTSTART:20170830T180800Z DTEND:20170831T223400Z SEQUENCE:2 END:VEVENT END:VCALENDAR""" ICS_SAMPLE_WITH_TIMEZONES = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VTIMEZONE TZID:(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:CUSTOM TZ BEGIN:DAYLIGHT TZOFFSETFROM:+0300 TZOFFSETTO:+0400 TZNAME:WTF0 DTSTART:19700101T020000 RRULE:FREQ=MONTHLY;BYMONTH=1,3,5,7,9,11 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0400 TZOFFSETTO:+0300 TZNAME:WTF1 DTSTART:19700201T030000 RRULE:FREQ=MONTHLY;BYMONTH=2,4,6,8,10,12 END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID="(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris":20171213T120100 DTEND;TZID="(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris":20171213T120200 END:VEVENT BEGIN:VEVENT DTSTART;TZID="CUSTOM TZ":20180101T112233 DTEND;TZID="CUSTOM TZ":20180202T112233 END:VEVENT BEGIN:VEVENT DTSTART:20190102T030405Z DTEND:20190504T030201Z END:VEVENT END:VCALENDAR """ ICS_SAMPLE_WITH_DURATION = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170824T082855Z DTSTART:20170831T170800Z DURATION:PT3H26M SEQUENCE:1 SUMMARY:Event 1 END:VEVENT BEGIN:VEVENT DTSTAMP:20170824T092855Z DTSTART:20170830T180800Z DURATION:P1DT4H26M SEQUENCE:2 SUMMARY:Event 2 END:VEVENT END:VCALENDAR""" ICS_SAMPLE_WITH_RECURRENT_EVENT = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170720T145803Z DESCRIPTION:Vacances d'ete DTSTART;VALUE=DATE:20180101 DTEND;VALUE=DATE:20180101 SUMMARY:reccurent event END:VEVENT BEGIN:VEVENT DTSTAMP:20180824T082855Z DTSTART:20180101 DTEND:20180101 SUMMARY:New Year's Eve RRULE:FREQ=YEARLY END:VEVENT END:VCALENDAR""" ICS_SAMPLE_WITH_RECURRENT_EVENT_IN_THE_PAST = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20180824T082855Z DTSTART:20120101 DURATION:PT24H SUMMARY:New Year's Eve RRULE:FREQ=YEARLY END:VEVENT END:VCALENDAR""" ICS_SAMPLE_WITH_NO_EVENTS = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN END:VCALENDAR""" INVALID_ICS_SAMPLE = """content """ with open('tests/data/atreal.ics') as f: ICS_ATREAL = f.read() with open('tests/data/holidays.ics') as f: ICS_HOLIDAYS = f.read() def test_slug(): agenda = Agenda(label='Foo bar') agenda.save() assert agenda.slug == 'foo-bar' def test_existing_slug(): agenda = Agenda(label='Foo bar', slug='bar') agenda.save() assert agenda.slug == 'bar' def test_duplicate_slugs(): agenda = Agenda(label='Foo baz') agenda.save() assert agenda.slug == 'foo-baz' agenda = Agenda(label='Foo baz') agenda.save() assert agenda.slug == 'foo-baz-1' agenda = Agenda(label='Foo baz') agenda.save() assert agenda.slug == 'foo-baz-2' def test_resource_slug(): resource = Resource.objects.create(label='Foo bar') assert resource.slug == 'foo-bar' def test_resource_existing_slug(): resource = Resource.objects.create(label='Foo bar', slug='bar') assert resource.slug == 'bar' def test_resource_duplicate_slugs(): resource = Resource.objects.create(label='Foo baz') assert resource.slug == 'foo-baz' resource = Resource.objects.create(label='Foo baz') assert resource.slug == 'foo-baz-1' resource = Resource.objects.create(label='Foo baz') assert resource.slug == 'foo-baz-2' def test_category_slug(): category = Category.objects.create(label='Foo bar') assert category.slug == 'foo-bar' def test_category_existing_slug(): category = Category.objects.create(label='Foo bar', slug='bar') assert category.slug == 'bar' def test_category_duplicate_slugs(): category = Category.objects.create(label='Foo baz') assert category.slug == 'foo-baz' category = Category.objects.create(label='Foo baz') assert category.slug == 'foo-baz-1' category = Category.objects.create(label='Foo baz') assert category.slug == 'foo-baz-2' def test_agenda_minimal_booking_delay(freezer): freezer.move_to('2021-07-09') agenda = Agenda.objects.create(label='Agenda', minimal_booking_delay=4) assert agenda.min_booking_datetime == make_aware(datetime.datetime(2021, 7, 13, 0, 0, 0)) freezer.move_to('2022-03-18') del agenda.min_booking_datetime assert agenda.min_booking_datetime == make_aware(datetime.datetime(2022, 3, 22, 0, 0, 0)) # DST change on sunday freezer.move_to('2022-03-25') del agenda.min_booking_datetime assert agenda.min_booking_datetime == make_aware(datetime.datetime(2022, 3, 29, 0, 0, 0)) # DST change on sunday freezer.move_to('2021-10-29') del agenda.min_booking_datetime assert agenda.min_booking_datetime == make_aware(datetime.datetime(2021, 11, 2, 0, 0, 0)) def test_agenda_minimal_booking_delay_in_working_days(settings, freezer): freezer.move_to('2021-07-09') agenda = build_agendas('meetings Agenda minimal_booking_delay=1') settings.WORKING_DAY_CALENDAR = None agenda.minimal_booking_delay_in_working_days = True agenda.save() assert agenda.min_booking_datetime.date() == datetime.date(2021, 7, 10) agenda.minimal_booking_delay_in_working_days = False agenda.save() del agenda.min_booking_datetime assert agenda.min_booking_datetime.date() == datetime.date(2021, 7, 10) settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France' agenda.minimal_booking_delay_in_working_days = True agenda.save() del agenda.min_booking_datetime assert agenda.min_booking_datetime.date() == datetime.date(2021, 7, 12) agenda.minimal_booking_delay_in_working_days = False agenda.save() del agenda.min_booking_datetime assert agenda.min_booking_datetime.date() == datetime.date(2021, 7, 10) agenda = Agenda.objects.create(label='Agenda', minimal_booking_delay=2) agenda.minimal_booking_delay_in_working_days = True agenda.save() freezer.move_to('2022-03-18') assert agenda.min_booking_datetime == make_aware(datetime.datetime(2022, 3, 22, 0, 0, 0)) # DST change on sunday freezer.move_to('2022-03-25') del agenda.min_booking_datetime assert agenda.min_booking_datetime == make_aware(datetime.datetime(2022, 3, 29, 0, 0, 0)) # DST change on sunday, and first of november is off freezer.move_to('2021-10-29') del agenda.min_booking_datetime assert agenda.min_booking_datetime == make_aware(datetime.datetime(2021, 11, 3, 0, 0, 0)) def test_agenda_maximal_booking_delay(freezer): freezer.move_to('2021-07-09') agenda = Agenda.objects.create(label='Agenda', maximal_booking_delay=4) assert agenda.max_booking_datetime == make_aware(datetime.datetime(2021, 7, 13, 0, 0, 0)) freezer.move_to('2022-03-18') del agenda.max_booking_datetime assert agenda.max_booking_datetime == make_aware(datetime.datetime(2022, 3, 22, 0, 0, 0)) # DST change on sunday freezer.move_to('2022-03-25') del agenda.max_booking_datetime assert agenda.max_booking_datetime == make_aware(datetime.datetime(2022, 3, 29, 0, 0, 0)) # DST change on sunday freezer.move_to('2021-10-29') del agenda.max_booking_datetime assert agenda.max_booking_datetime == make_aware(datetime.datetime(2021, 11, 2, 0, 0, 0)) def delay_parameter_to_label(argvalue): if isinstance(argvalue, str): return argvalue return repr(argvalue) @pytest.mark.parametrize( 'current_time,min_booking_datetime', [ ('2021-07-09T08:00:00+02:00', datetime.datetime(2021, 7, 13, 0, 0)), ('2021-03-18T07:00:00+01:00', datetime.datetime(2021, 3, 22, 0, 0)), # summer DST change on sunday 28th ('2021-03-25T01:30:00+01:00', datetime.datetime(2021, 3, 29, 0, 0)), ('2021-03-25T02:30:00+01:00', datetime.datetime(2021, 3, 29, 0, 0)), ('2021-03-25T03:30:00+01:00', datetime.datetime(2021, 3, 29, 0, 0)), ('2021-03-28T01:30:00+01:00', datetime.datetime(2021, 4, 1, 0, 0)), ('2021-03-28T03:30:00+02:00', datetime.datetime(2021, 4, 1, 0, 0)), # winter DST change on sunday 31th ('2021-10-29T01:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-31T01:30:00+02:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T02:30:00+02:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T02:30:00+01:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T03:30:00+01:00', datetime.datetime(2021, 11, 4, 0, 0)), ], ids=delay_parameter_to_label, ) def test_agenda_minimal_booking_delay_no_minimal_booking_time(freezer, current_time, min_booking_datetime): freezer.move_to(current_time) agenda = build_agendas('meetings Agenda minimal_booking_delay=4 minimal_booking_time=None') assert make_naive(agenda.min_booking_datetime) == min_booking_datetime @pytest.mark.parametrize( 'current_time,min_booking_datetime', [ ('2021-07-09T08:00:00+02:00', datetime.datetime(2021, 7, 16, 0, 0)), ('2021-03-18T07:00:00+01:00', datetime.datetime(2021, 3, 24, 0, 0)), # summer DST change on sunday 28th ('2021-03-25T01:30:00+01:00', datetime.datetime(2021, 3, 31, 0, 0)), ('2021-03-25T02:30:00+01:00', datetime.datetime(2021, 3, 31, 0, 0)), ('2021-03-25T03:30:00+01:00', datetime.datetime(2021, 3, 31, 0, 0)), ('2021-03-28T01:30:00+01:00', datetime.datetime(2021, 4, 1, 0, 0)), ('2021-03-28T03:30:00+02:00', datetime.datetime(2021, 4, 1, 0, 0)), # winter DST change on sunday 31th ('2021-10-29T01:30:00+02:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-31T01:30:00+02:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-31T02:30:00+02:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-31T02:30:00+01:00', datetime.datetime(2021, 11, 5, 0, 0)), ('2021-10-31T03:30:00+01:00', datetime.datetime(2021, 11, 5, 0, 0)), ], ids=delay_parameter_to_label, ) def test_agenda_minimal_booking_delay_in_working_days_no_minimal_booking_time( settings, freezer, current_time, min_booking_datetime ): settings.WORKING_DAY_CALENDAR = 'workalendar.europe.France' freezer.move_to(current_time) agenda = Agenda.objects.create( label='Agenda', minimal_booking_delay=4, minimal_booking_time=None, minimal_booking_delay_in_working_days=True, ) assert make_naive(agenda.min_booking_datetime) == min_booking_datetime @pytest.mark.parametrize( 'current_time,min_booking_datetime', [ ('2021-07-09T08:00:00+02:00', datetime.datetime(2021, 7, 13, 0, 0)), ('2021-03-18T07:00:00+01:00', datetime.datetime(2021, 3, 22, 0, 0)), # summer DST change on sunday ('2021-03-25T02:30:00+01:00', datetime.datetime(2021, 3, 29, 0, 0)), ('2021-03-25T03:30:00+01:00', datetime.datetime(2021, 3, 29, 0, 0)), # winter DST change on sunday ('2021-10-29T01:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 2, 0, 0)), ('2021-10-31T01:30:00+02:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T02:30:00+02:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T02:30:00+01:00', datetime.datetime(2021, 11, 4, 0, 0)), ('2021-10-31T03:30:00+01:00', datetime.datetime(2021, 11, 4, 0, 0)), ], ids=delay_parameter_to_label, ) def test_agenda_minimal_booking_delay_minimal_booking_time_at_8(freezer, current_time, min_booking_datetime): freezer.move_to(current_time) agenda = Agenda.objects.create( label='Agenda', minimal_booking_delay=4, minimal_booking_time=datetime.time(8) ) assert make_naive(agenda.min_booking_datetime) == min_booking_datetime @pytest.mark.parametrize( 'current_time,max_booking_datetime', [ ('2021-07-09T08:00:00+02:00', datetime.datetime(2021, 7, 12, 8)), ('2021-03-18T07:00:00+01:00', datetime.datetime(2021, 3, 21, 7)), # summer DST change on sunday 28th ('2021-03-25T01:30:00+01:00', datetime.datetime(2021, 3, 28, 1, 30)), ('2021-03-25T02:30:00+01:00', datetime.datetime(2021, 3, 28, 3, 30)), ('2021-03-25T03:30:00+01:00', datetime.datetime(2021, 3, 28, 3, 30)), ('2021-03-28T01:30:00+01:00', datetime.datetime(2021, 3, 31, 1, 30)), ('2021-03-28T03:30:00+02:00', datetime.datetime(2021, 3, 31, 3, 30)), # winter DST change on sunday 31th ('2021-10-29T01:30:00+02:00', datetime.datetime(2021, 11, 1, 1, 30)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 1, 2, 30)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 1, 2, 30)), ('2021-10-31T01:30:00+02:00', datetime.datetime(2021, 11, 3, 1, 30)), ('2021-10-31T02:30:00+02:00', datetime.datetime(2021, 11, 3, 2, 30)), ('2021-10-31T02:30:00+01:00', datetime.datetime(2021, 11, 3, 2, 30)), ('2021-10-31T03:30:00+01:00', datetime.datetime(2021, 11, 3, 3, 30)), ], ids=delay_parameter_to_label, ) def test_agenda_maximal_booking_delay_no_minimal_booking_time(freezer, current_time, max_booking_datetime): freezer.move_to(current_time) agenda = Agenda.objects.create(label='Agenda', maximal_booking_delay=4, minimal_booking_time=None) assert make_naive(agenda.max_booking_datetime) == max_booking_datetime @pytest.mark.parametrize( 'current_time,max_booking_datetime', [ ('2021-07-09T08:00:00+02:00', datetime.datetime(2021, 7, 13, 0)), ('2021-03-18T07:00:00+01:00', datetime.datetime(2021, 3, 21, 0)), # summer DST change on sunday ('2021-03-25T02:30:00+01:00', datetime.datetime(2021, 3, 28, 0)), ('2021-03-25T03:30:00+01:00', datetime.datetime(2021, 3, 28, 0)), # winter DST change on sunday ('2021-10-29T01:30:00+02:00', datetime.datetime(2021, 11, 1, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 1, 0)), ('2021-10-29T02:30:00+02:00', datetime.datetime(2021, 11, 1, 0)), ('2021-10-31T01:30:00+02:00', datetime.datetime(2021, 11, 3, 0)), ('2021-10-31T02:30:00+02:00', datetime.datetime(2021, 11, 3, 0)), ('2021-10-31T02:30:00+01:00', datetime.datetime(2021, 11, 3, 0)), ('2021-10-31T03:30:00+01:00', datetime.datetime(2021, 11, 3, 0)), ], ids=delay_parameter_to_label, ) def test_agenda_maximal_booking_delay_minimal_booking_time_at_8(freezer, current_time, max_booking_datetime): freezer.move_to(current_time) agenda = Agenda.objects.create( label='Agenda', maximal_booking_delay=4, minimal_booking_time=datetime.time(8) ) assert make_naive(agenda.max_booking_datetime) == max_booking_datetime @pytest.mark.parametrize('with_prefetch', [True, False]) def test_agenda_is_available_for_simple_management(settings, with_prefetch): settings.EXCEPTIONS_SOURCES = { 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } def check_is_available(result, use_prefetch=True): agenda = Agenda.objects.get() if with_prefetch and use_prefetch: agenda.prefetch_desks_and_exceptions(with_sources=True, min_date=now()) assert agenda.is_available_for_simple_management() == result agenda = Agenda.objects.create(label='Agenda', kind='meetings') # no desks check_is_available(True) # check kind agenda.kind = 'events' agenda.save() check_is_available(False, use_prefetch=False) agenda.kind = 'virtual' agenda.save() check_is_available(False, use_prefetch=False) # only one desk agenda.kind = 'meetings' agenda.save() desk = Desk.objects.create(label='Desk', agenda=agenda) check_is_available(True) # create some related data for this desk time_period = TimePeriod.objects.create( weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) ) desk.import_timeperiod_exceptions_from_settings(enable=True) source1 = desk.timeperiodexceptionsource_set.get(settings_slug='holidays') source2 = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics') source3 = TimePeriodExceptionSource.objects.create( desk=desk, ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics'), ) date_now = now() exception = TimePeriodException.objects.create( label='Exception', desk=desk, start_datetime=date_now + datetime.timedelta(days=1), end_datetime=date_now + datetime.timedelta(days=2), ) unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') unavailability_calendar.desks.add(desk) unavailability_calendar2 = UnavailabilityCalendar.objects.create(label='Calendar 2') # still ok check_is_available(True) # duplicate the desk twice desk2 = desk.duplicate() desk.duplicate() # still ok check_is_available(True) # changes on time periods for _desk in [desk, desk2]: time_period2 = TimePeriod.objects.create( weekday=2, desk=_desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) ) check_is_available(False) time_period2.delete() check_is_available(True) time_period.weekday = 2 time_period.save() check_is_available(False) time_period.weekday = 1 time_period.start_time = datetime.time(10, 1) time_period.save() check_is_available(False) time_period.start_time = datetime.time(10, 0) time_period.end_time = datetime.time(12, 1) time_period.save() check_is_available(False) time_period.end_time = datetime.time(12, 0) time_period.save() check_is_available(True) # changes on exceptions for _desk in [desk, desk2]: exception2 = TimePeriodException.objects.create( label='Exception', desk=_desk, start_datetime=date_now + datetime.timedelta(days=3), end_datetime=date_now + datetime.timedelta(days=4), ) check_is_available(False) exception2.delete() check_is_available(True) exception.label = 'Exception blah' exception.save() check_is_available(False) exception.label = 'Exception' exception.start_datetime = date_now + datetime.timedelta(days=3) exception.save() check_is_available(False) exception.start_datetime = date_now + datetime.timedelta(days=1) exception.end_datetime = date_now + datetime.timedelta(days=1) exception.save() check_is_available(False) exception.end_datetime = date_now + datetime.timedelta(days=2) exception.save() check_is_available(True) # exceptions from source are not checked exception3 = TimePeriodException.objects.create( label='Exception', desk=desk, start_datetime=date_now + datetime.timedelta(days=3), end_datetime=date_now + datetime.timedelta(days=4), source=source2, ) check_is_available(True) exception3.delete() # changes on sources - from settings for _desk in [desk, desk2]: source = TimePeriodExceptionSource.objects.create( desk=_desk, settings_slug='holidays-bis', enabled=True ) check_is_available(False) source.delete() check_is_available(True) source1.enabled = False source1.save() check_is_available(False) source1.enabled = True source1.settings_slug = 'holidays-bis' source1.save() check_is_available(False) source1.settings_slug = 'holidays' source1.save() check_is_available(True) # changes on sources - from url for _desk in [desk, desk2]: source = TimePeriodExceptionSource.objects.create( desk=_desk, ics_url='http://example.com/sample-bis.ics' ) check_is_available(False) source.delete() check_is_available(True) source2.ics_url = 'http://example.com/sample-bis.ics' source2.save() check_is_available(False) source2.ics_url = 'http://example.com/sample.ics' source2.save() check_is_available(True) # changes on sources - from file for _desk in [desk, desk2]: source = TimePeriodExceptionSource.objects.create( desk=_desk, ics_filename='sample-bis.ics', ics_file=ContentFile(ICS_SAMPLE, name='sample-bis.ics'), ) check_is_available(False) source.delete() check_is_available(True) source3.ics_filename = 'sample-bis.ics' source3.save() check_is_available(False) source3.ics_filename = 'sample.ics' source3.save() check_is_available(True) # ics_file content is not checked # changes on unavailability calendars for _desk in [desk, desk2]: unavailability_calendar2.desks.add(_desk) check_is_available(False) unavailability_calendar2.desks.remove(_desk) check_is_available(True) def test_event_slug(): other_agenda = Agenda.objects.create(label='Foo bar') Event.objects.create(agenda=other_agenda, places=42, start_datetime=now(), slug='foo-bar') agenda = Agenda.objects.create(label='Foo baz') event = Event.objects.create(agenda=agenda, places=42, start_datetime=now(), label='Foo bar') assert event.slug == 'foo-bar' event = Event.objects.create(agenda=agenda, places=42, start_datetime=now(), label='Foo bar') assert event.slug == 'foo-bar-1' event = Event.objects.create(agenda=agenda, places=42, start_datetime=now(), label='Foo bar') assert event.slug == 'foo-bar-2' event = Event.objects.create(agenda=agenda, places=42, start_datetime=now()) assert event.slug == 'foo-baz-event' event = Event.objects.create(agenda=agenda, places=42, start_datetime=now()) assert event.slug == 'foo-baz-event-1' event = Event.objects.create(agenda=agenda, places=42, start_datetime=now()) assert event.slug == 'foo-baz-event-2' def test_event_existing_slug(): other_agenda = Agenda.objects.create(label='Foo bar') Event.objects.create(agenda=other_agenda, places=42, start_datetime=now(), slug='bar') agenda = Agenda.objects.create(label='Foo baz') event = Event.objects.create(agenda=agenda, places=42, start_datetime=now(), label='Foo bar', slug='bar') assert event.slug == 'bar' def test_event_manager(): agenda = Agenda(label='Foo baz') agenda.save() event = Event(start_datetime=now(), places=10, agenda=agenda) event.save() booking = Booking(event=event) booking.save() assert Event.objects.all()[0].booked_places == 1 booking.cancellation_datetime = now() booking.save() assert Event.objects.all()[0].booked_places == 0 @pytest.mark.parametrize( 'start_days, start_minutes, min_delay, max_delay, pub_days, expected', [ # no delay (10, 0, 0, 0, None, True), # test publication_datetime (10, 0, 0, 0, 1, False), (10, 0, 0, 0, 0, True), # test min and max delays (10, 0, 20, 0, None, False), (10, 0, 1, 5, None, False), (10, 0, 1, 20, None, True), # special case for events that happens today (0, 10, 0, 20, None, True), (0, -10, 0, 20, None, False), ], ) def test_event_bookable_period(start_days, start_minutes, min_delay, max_delay, pub_days, expected): agenda = Agenda.objects.create( label='Foo bar', minimal_booking_delay=min_delay, maximal_booking_delay=max_delay ) event = Event.objects.create( start_datetime=localtime() + datetime.timedelta(days=start_days, minutes=start_minutes), publication_datetime=(localtime() + datetime.timedelta(days=pub_days)) if pub_days else None, places=10, agenda=agenda, ) assert event.in_bookable_period() == expected def test_meeting_type_slugs(): agenda1 = Agenda(label='Foo bar') agenda1.save() agenda2 = Agenda(label='Foo bar second') agenda2.save() meeting_type1 = MeetingType(agenda=agenda1, label='Baz') meeting_type1.save() assert meeting_type1.slug == 'baz' meeting_type2 = MeetingType(agenda=agenda1, label='Baz') meeting_type2.save() assert meeting_type2.slug == 'baz-1' meeting_type3 = MeetingType(agenda=agenda2, label='Baz') meeting_type3.save() assert meeting_type3.slug == 'baz' def test_timeperiodexception_creation_from_ics(): agenda = build_agendas( '''\ meetings "Test 1 agenda" desk "Test 1 desk" exception-source sample.ics ICS_SAMPLE''' ) agenda._test_1_desk._sample_ics.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=agenda._test_1_desk).count() == 2 def test_timeperiodexception_creation_from_ics_without_startdt(): agenda = Agenda.objects.create(label='Test 2 agenda') desk = Desk.objects.create(label='Test 2 desk', agenda=agenda) lines = [] # remove start datetimes from ics for line in ICS_SAMPLE.splitlines(): if line.startswith('DTSTART:'): continue lines.append(line) ics_sample = ContentFile('\n'.join(lines), name='sample.ics') source = desk.timeperiodexceptionsource_set.create(ics_filename='sample.ics', ics_file=ics_sample) with pytest.raises(ICSError) as e: source._check_ics_content() assert str(e.value) == 'Event "Évènement 1" has no start date.' def test_timeperiodexception_creation_from_ics_without_enddt(): agenda = Agenda.objects.create(label='Test 3 agenda') desk = Desk.objects.create(label='Test 3 desk', agenda=agenda) lines = [] # remove end datetimes from ics for line in ICS_SAMPLE.splitlines(): if line.startswith('DTEND:'): continue lines.append(line) ics_sample = ContentFile('\n'.join(lines), name='sample.ics') source = desk.timeperiodexceptionsource_set.create(ics_filename='sample.ics', ics_file=ics_sample) source.refresh_timeperiod_exceptions_from_ics() for exception in TimePeriodException.objects.filter(desk=desk): end_time = localtime(exception.end_datetime).time() assert end_time == datetime.time(23, 59, 59, 999999) @pytest.mark.freeze_time('2017-12-01') def test_timeperiodexception_creation_from_ics_with_recurrences(): agenda = Agenda.objects.create(label='Test 4 agenda') desk = Desk.objects.create(label='Test 4 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_RECURRENT_EVENT, name='sample.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 3 def test_timeexception_creation_from_ics_with_dates(): agenda = Agenda.objects.create(label='Test 5 agenda') desk = Desk.objects.create(label='Test 5 desk', agenda=agenda) lines = [] # remove end datetimes from ics for line in ICS_SAMPLE_WITH_RECURRENT_EVENT.splitlines(): if line.startswith('RRULE:'): continue lines.append(line) ics_sample = ContentFile('\n'.join(lines), name='sample.ics') source = desk.timeperiodexceptionsource_set.create(ics_filename='sample.ics', ics_file=ics_sample) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 for exception in TimePeriodException.objects.filter(desk=desk): assert localtime(exception.start_datetime) == make_aware(datetime.datetime(2018, 1, 1, 0, 0)) assert localtime(exception.end_datetime) == make_aware(datetime.datetime(2018, 1, 1, 0, 0)) def test_timeexception_create_from_invalid_ics(): agenda = Agenda.objects.create(label='Test 6 agenda') desk = Desk.objects.create(label='Test 6 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(INVALID_ICS_SAMPLE, name='sample.ics') ) with pytest.raises(ICSError) as e: source._check_ics_content() assert str(e.value) == 'File format is invalid.' def test_timeexception_create_from_ics_with_no_events(): agenda = Agenda.objects.create(label='Test 7 agenda') desk = Desk.objects.create(label='Test 7 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_NO_EVENTS, name='sample.ics') ) with pytest.raises(ICSError) as e: source._check_ics_content() assert str(e.value) == "The file doesn't contain any events." @mock.patch('chrono.agendas.models.requests.get') def test_timeperiodexception_creation_from_remote_ics(mocked_get): agenda = Agenda.objects.create(label='Test 8 agenda') desk = Desk.objects.create(label='Test 8 desk', agenda=agenda) mocked_response = mock.Mock() mocked_response.text = ICS_SAMPLE mocked_get.return_value = mocked_response source = desk.timeperiodexceptionsource_set.create(ics_url='http://example.com/sample.ics') source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 assert 'Évènement 1' in [x.label for x in desk.timeperiodexception_set.all()] mocked_response.text = ICS_SAMPLE_WITH_NO_EVENTS mocked_get.return_value = mocked_response with pytest.raises(ICSError) as e: source._check_ics_content() assert str(e.value) == "The file doesn't contain any events." @mock.patch('chrono.agendas.models.requests.get') def test_timeperiodexception_remote_ics_encoding(mocked_get): agenda = Agenda.objects.create(label='Test 8 agenda') desk = Desk.objects.create(label='Test 8 desk', agenda=agenda) mocked_response = mock.Mock() mocked_response.content = ICS_SAMPLE.encode('iso-8859-15') mocked_response.text = ICS_SAMPLE mocked_get.return_value = mocked_response source = desk.timeperiodexceptionsource_set.create(ics_url='http://example.com/sample.ics') source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 assert 'Évènement 1' in [x.label for x in desk.timeperiodexception_set.all()] @mock.patch('chrono.agendas.models.requests.get') def test_timeperiodexception_creation_from_unreachable_remote_ics(mocked_get): agenda = Agenda.objects.create(label='Test 9 agenda') desk = Desk.objects.create(label='Test 9 desk', agenda=agenda) mocked_response = mock.Mock() mocked_response.text = ICS_SAMPLE mocked_get.return_value = mocked_response def mocked_requests_connection_error(*args, **kwargs): raise requests.ConnectionError('unreachable') source = desk.timeperiodexceptionsource_set.create(ics_url='http://example.com/sample.ics') mocked_get.side_effect = mocked_requests_connection_error with pytest.raises(ICSError) as e: source._check_ics_content() assert str(e.value) == 'Failed to retrieve remote calendar (http://example.com/sample.ics, unreachable).' @mock.patch('chrono.agendas.models.requests.get') def test_timeperiodexception_creation_from_forbidden_remote_ics(mocked_get): agenda = Agenda.objects.create(label='Test 10 agenda') desk = Desk.objects.create(label='Test 10 desk', agenda=agenda) mocked_response = mock.Mock() mocked_response.status_code = 403 mocked_get.return_value = mocked_response def mocked_requests_http_forbidden_error(*args, **kwargs): raise requests.HTTPError(response=mocked_response) source = desk.timeperiodexceptionsource_set.create(ics_url='http://example.com/sample.ics') mocked_get.side_effect = mocked_requests_http_forbidden_error with pytest.raises(ICSError) as e: source._check_ics_content() assert ( str(e.value) == 'Failed to retrieve remote calendar (http://example.com/sample.ics, HTTP error 403).' ) @mock.patch('chrono.agendas.models.requests.get') def test_sync_desks_timeperiod_exceptions_from_ics(mocked_get, capsys): agenda = Agenda.objects.create(label='Test 11 agenda') desk = Desk.objects.create(label='Test 11 desk', agenda=agenda) source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics') mocked_response = mock.Mock() mocked_response.status_code = 403 mocked_get.return_value = mocked_response def mocked_requests_http_forbidden_error(*args, **kwargs): raise requests.HTTPError(response=mocked_response) mocked_get.side_effect = mocked_requests_http_forbidden_error call_command('sync_desks_timeperiod_exceptions') err = capsys.readouterr()[1] assert ( err == 'unable to create timeperiod exceptions for "Test 11 desk": Failed to retrieve remote calendar (http://example.com/sample.ics, HTTP error 403).\n' ) assert source.ics_url is not None assert source.ics_filename is None assert source.ics_file.name is None with mock.patch( 'chrono.agendas.models.TimePeriodExceptionSource.refresh_timeperiod_exceptions_from_ics' ) as refresh: call_command('sync_desks_timeperiod_exceptions') assert refresh.call_args_list == [mock.call()] source.ics_url = None source.ics_filename = 'sample.ics' source.ics_file = ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics') source.save() with mock.patch( 'chrono.agendas.models.TimePeriodExceptionSource.refresh_timeperiod_exceptions_from_ics' ) as refresh: call_command('sync_desks_timeperiod_exceptions') assert refresh.call_args_list == [mock.call()] TimePeriodExceptionSource.objects.update(ics_file='') with mock.patch( 'chrono.agendas.models.TimePeriodExceptionSource.refresh_timeperiod_exceptions_from_ics' ) as refresh: call_command('sync_desks_timeperiod_exceptions') assert refresh.call_args_list == [] TimePeriodExceptionSource.objects.update(ics_file=None) with mock.patch( 'chrono.agendas.models.TimePeriodExceptionSource.refresh_timeperiod_exceptions_from_ics' ) as refresh: call_command('sync_desks_timeperiod_exceptions') assert refresh.call_args_list == [] @override_settings( EXCEPTIONS_SOURCES={ 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } ) def test_timeperiodexception_from_settings(): agenda = Agenda(label='Test 1 agenda') agenda.save() desk = Desk(label='Test 1 desk', agenda=agenda) desk.save() desk.import_timeperiod_exceptions_from_settings(enable=True) source = TimePeriodExceptionSource.objects.get(desk=desk) assert source.settings_slug == 'holidays' assert source.enabled assert TimePeriodException.objects.filter(desk=desk, source=source).exists() exception = TimePeriodException.objects.first() from workalendar.europe import France date, label = France().holidays()[0] exception = TimePeriodException.objects.filter(label=label).first() assert exception.end_datetime - exception.start_datetime == datetime.timedelta(days=1) assert localtime(exception.start_datetime).date() == date source.disable() assert not source.enabled assert not TimePeriodException.objects.filter(desk=desk, source=source).exists() source.enable() assert source.enabled assert TimePeriodException.objects.filter(desk=desk, source=source).exists() def test_timeperiodexception_from_settings_command(): setting = { 'EXCEPTIONS_SOURCES': { 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } } agenda = Agenda(label='Test 1 agenda') agenda.save() desk1 = Desk(label='Test 1 desk', agenda=agenda) desk1.save() assert not TimePeriodExceptionSource.objects.filter(desk=desk1).exists() with override_settings(**setting): desk2 = Desk(label='Test 2 desk', agenda=agenda) desk2.save() desk2.import_timeperiod_exceptions_from_settings(enable=True) desk3 = Desk(label='Test 3 desk', agenda=agenda) desk3.save() desk3.import_timeperiod_exceptions_from_settings(enable=False) call_command('sync_desks_timeperiod_exceptions_from_settings') assert not TimePeriodExceptionSource.objects.get(desk=desk1).enabled source2 = TimePeriodExceptionSource.objects.get(desk=desk2) assert source2.enabled source3 = TimePeriodExceptionSource.objects.get(desk=desk3) assert not source3.enabled exceptions_count = source2.timeperiodexception_set.count() # Alsace Moselle has more holidays setting['EXCEPTIONS_SOURCES']['holidays']['class'] = 'workalendar.europe.FranceAlsaceMoselle' with override_settings(**setting): call_command('sync_desks_timeperiod_exceptions_from_settings') source2.refresh_from_db() assert exceptions_count < source2.timeperiodexception_set.count() setting['LANGUAGE_CODE'] = 'fr-fr' with override_settings(**setting): call_command('sync_desks_timeperiod_exceptions_from_settings') assert not TimePeriodException.objects.filter(label='All Saints Day').exists() assert TimePeriodException.objects.filter(label='Toussaint').exists() setting['EXCEPTIONS_SOURCES'] = {} with override_settings(**setting): call_command('sync_desks_timeperiod_exceptions_from_settings') assert not TimePeriodExceptionSource.objects.exists() def test_timeperiodexception_groups(): unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') source = unavailability_calendar.timeperiodexceptionsource_set.create( ics_filename='holidays.ics', ics_file=ContentFile(ICS_HOLIDAYS, name='holidays.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.count() == 11 group1, group2 = TimePeriodExceptionGroup.objects.all() assert group1.label == 'Vacances de Noël' assert group1.slug == 'christmas_holidays' assert group2.label == 'Vacances d’Été' assert group2.slug == 'summer_holidays' assert group1.exceptions.count() == 6 assert group2.exceptions.count() == 5 unavailability_calendar.delete() assert not TimePeriodException.objects.exists() assert not TimePeriodExceptionGroup.objects.exists() # check no groups are created for desks agenda = Agenda.objects.create(label='Test 1 agenda') desk = Desk.objects.create(label='Test 1 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='holidays.ics', ics_file=ContentFile(ICS_HOLIDAYS, name='holidays.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.count() == 11 assert not TimePeriodExceptionGroup.objects.exists() def test_base_meeting_duration(): agenda = Agenda(label='Meeting', kind='meetings') agenda.save() with pytest.raises(ValueError): agenda.get_base_meeting_duration() meeting_type = MeetingType(agenda=agenda, label='Foo', duration=0) meeting_type.save() with pytest.raises(ValueError): agenda.get_base_meeting_duration() meeting_type = MeetingType(agenda=agenda, label='Foo', duration=30) meeting_type.save() del agenda.__dict__['cached_meetingtypes'] assert agenda.get_base_meeting_duration() == 30 meeting_type = MeetingType(agenda=agenda, label='Bar', duration=60) meeting_type.save() del agenda.__dict__['cached_meetingtypes'] assert agenda.get_base_meeting_duration() == 30 meeting_type = MeetingType(agenda=agenda, label='Bar', duration=45) meeting_type.save() del agenda.__dict__['cached_meetingtypes'] assert agenda.get_base_meeting_duration() == 15 def test_timeperiodexception_creation_from_ics_with_duration(): # test that event defined using duration works and give the same start and # end dates agenda = Agenda.objects.create(label='Test 1 agenda') desk = Desk.objects.create(label='Test 1 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == { make_aware(datetime.datetime(2017, 8, 31, 19, 8, 0)), make_aware(datetime.datetime(2017, 8, 30, 20, 8, 0)), } assert set(TimePeriodException.objects.values_list('end_datetime', flat=True)) == { make_aware(datetime.datetime(2017, 8, 31, 22, 34, 0)), make_aware(datetime.datetime(2017, 9, 1, 0, 34, 0)), } def test_timeperiodexception_creation_from_ics_with_timezone(): agenda = Agenda.objects.create(label='Test 1 agenda') desk = Desk.objects.create(label='Test 1 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_TIMEZONES, name='sample.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 3 expt_start = '2017-12-13T12:01:00+0100', '2018-01-01T11:22:33+0400', '2019-01-02T03:04:05Z' expt_end = '2017-12-13T12:02:00+0100', '2018-02-02T11:22:33+0300', '2019-05-04T03:02:01Z' assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == { datetime.datetime.fromisoformat(dt) for dt in expt_start } assert set(TimePeriodException.objects.values_list('end_datetime', flat=True)) == { datetime.datetime.fromisoformat(dt) for dt in expt_end } @pytest.mark.parametrize( 'bad_ics_content', [ pytest.param( """BBEGIN:nothing VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170824T082855Z DTSTART:20170831T170800Z DTEND:20170831T203400Z SEQUENCE:1 SUMMARY:Évènement 1 END:VEVENT END:VCALENDAR""", marks=pytest.mark.comment('Missing BEGIN:VCALENDAR'), ), pytest.param( """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170824T082855Z DTSTART:2017-08-24T13:37:00 DTEND:20170831T203400Z SEQUENCE:1 SUMMARY:Évènement 1 END:VEVENT END:VCALENDAR""", marks=pytest.mark.comment('Bad DTSTART format'), ), pytest.param( """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN BEGIN:VEVENT DTSTAMP:20170824T082855Z DTSTART:20170830T203400Z DTEND:something SEQUENCE:1 SUMMARY:Évènement 1 END:VEVENT END:VCALENDAR""", marks=pytest.mark.comment('Bad DTEND format'), ), ], ) def test_timeperiodexception_creation_from_bad_ics(bad_ics_content): agenda = Agenda.objects.create(label='Test 1 agenda') desk = Desk.objects.create(label='Test 1 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(bad_ics_content, name='sample.ics') ) with pytest.raises(ICSError): source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 0 @pytest.mark.freeze_time('2017-12-01') def test_timeperiodexception_creation_from_ics_with_recurrences_in_the_past(): # test that recurrent events before today are not created # also test that duration + recurrent events works agenda = Agenda.objects.create(label='Test 4 agenda') desk = Desk.objects.create(label='Test 4 desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_RECURRENT_EVENT_IN_THE_PAST, name='sample.ics'), ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == { make_aware(datetime.datetime(2018, 1, 1)), make_aware(datetime.datetime(2019, 1, 1)), } def test_timeperiodexception_creation_from_ics_with_recurrences_atreal(): agenda = Agenda.objects.create(label='Test atreal agenda') desk = Desk.objects.create(label='Test atreal desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_ATREAL, name='sample.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).exists() def test_management_role_deletion(): group = Group(name='Group') group.save() agenda = Agenda(label='Test agenda', edit_role=group, view_role=group) agenda.save() Group.objects.all().delete() assert Agenda.objects.get(id=agenda.id).view_role is None assert Agenda.objects.get(id=agenda.id).edit_role is None def test_virtual_agenda_init(): agenda1 = Agenda.objects.create(label='Agenda 1', kind='meetings') agenda2 = Agenda.objects.create(label='Agenda 2', kind='meetings') virt_agenda = Agenda.objects.create(label='Virtual agenda', kind='virtual') VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1) VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2) virt_agenda.save() assert virt_agenda.real_agendas.count() == 2 assert virt_agenda.real_agendas.get(pk=agenda1.pk) assert virt_agenda.real_agendas.get(pk=agenda2.pk) for agenda in (agenda1, agenda2): assert agenda.virtual_agendas.count() == 1 assert agenda.virtual_agendas.get() == virt_agenda def test_virtual_agenda_base_meeting_duration(): virt_agenda = Agenda.objects.create(label='Virtual agenda', kind='virtual') with pytest.raises(ValueError): virt_agenda.get_base_meeting_duration() agenda1 = Agenda.objects.create(label='Agenda 1', kind='meetings') VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1) with pytest.raises(ValueError): virt_agenda.get_base_meeting_duration() meeting_type = MeetingType(agenda=agenda1, label='Foo', duration=30) meeting_type.save() del virt_agenda.__dict__['cached_meetingtypes'] assert virt_agenda.get_base_meeting_duration() == 30 meeting_type = MeetingType(agenda=agenda1, label='Bar', duration=60) meeting_type.save() del virt_agenda.__dict__['cached_meetingtypes'] assert virt_agenda.get_base_meeting_duration() == 30 agenda2 = Agenda.objects.create(label='Agenda 2', kind='meetings') VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2) virt_agenda.save() meeting_type = MeetingType(agenda=agenda2, label='Bar', duration=60) meeting_type.save() del virt_agenda.__dict__['cached_meetingtypes'] assert virt_agenda.get_base_meeting_duration() == 60 def test_agenda_get_effective_time_periods(db): real_agenda = Agenda.objects.create(label='Real Agenda', kind='meetings') MeetingType.objects.create(agenda=real_agenda, label='MT1') desk = Desk.objects.create(label='Real Agenda Desk1', agenda=real_agenda) time_period = TimePeriod.objects.create( weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0), desk=desk ) virtual_agenda = Agenda.objects.create(label='Virtual Agenda', kind='virtual') VirtualMember.objects.create(virtual_agenda=virtual_agenda, real_agenda=real_agenda) # empty exclusion set common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 1 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == time_period.start_time assert common_timeperiod.end_time == time_period.end_time # exclusions are on a different day def exclude_time_periods(time_periods): virtual_agenda.excluded_timeperiods.clear() for time_period in time_periods: time_period.agenda = virtual_agenda time_period.save() exclude_time_periods( [ TimePeriod(weekday=1, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0)), TimePeriod(weekday=2, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0)), ] ) common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 1 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == time_period.start_time assert common_timeperiod.end_time == time_period.end_time # one exclusion, end_time should be earlier exclude_time_periods( [TimePeriod(weekday=0, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0))] ) common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 1 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(10, 0) assert common_timeperiod.end_time == datetime.time(17, 0) # one exclusion, start_time should be later exclude_time_periods( [TimePeriod(weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(16, 0))] ) common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 1 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(16, 0) assert common_timeperiod.end_time == datetime.time(18, 0) # one exclusion, splits effective timeperiod in two exclude_time_periods( [TimePeriod(weekday=0, start_time=datetime.time(12, 0), end_time=datetime.time(16, 0))] ) common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 2 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(10, 0) assert common_timeperiod.end_time == datetime.time(12, 0) common_timeperiod = common_timeperiods[1] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(16, 0) assert common_timeperiod.end_time == datetime.time(18, 0) # several exclusion, splits effective timeperiod into pieces exclude_time_periods( [ TimePeriod(weekday=0, start_time=datetime.time(12, 0), end_time=datetime.time(13, 0)), TimePeriod(weekday=0, start_time=datetime.time(10, 30), end_time=datetime.time(11, 30)), TimePeriod(weekday=0, start_time=datetime.time(16, 30), end_time=datetime.time(17, 00)), ] ) common_timeperiods = list(virtual_agenda.get_effective_time_periods()) assert len(common_timeperiods) == 4 common_timeperiod = common_timeperiods[0] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(10, 0) assert common_timeperiod.end_time == datetime.time(10, 30) common_timeperiod = common_timeperiods[1] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(11, 30) assert common_timeperiod.end_time == datetime.time(12, 0) common_timeperiod = common_timeperiods[2] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(13, 0) assert common_timeperiod.end_time == datetime.time(16, 30) common_timeperiod = common_timeperiods[3] assert common_timeperiod.weekday == time_period.weekday assert common_timeperiod.start_time == datetime.time(17, 0) assert common_timeperiod.end_time == datetime.time(18, 0) def test_agenda_get_effective_time_periods_with_date(db): agenda = Agenda.objects.create(label='Test Agenda', kind='meetings') MeetingType.objects.create(agenda=agenda, label='MT1') desk = Desk.objects.create(label='Test Agenda Desk1', agenda=agenda) TimePeriod.objects.create( date=datetime.date(2022, 1, 1), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0), desk=desk, ) TimePeriod.objects.create( date=datetime.date(2022, 1, 8), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0), desk=desk, ) assert len(list(agenda.get_effective_time_periods())) == 2 def test_agenda_get_effective_time_periods_with_weekday_indexes(db): agenda = Agenda.objects.create(label='Test Agenda', kind='meetings') MeetingType.objects.create(agenda=agenda, label='MT1') desk = Desk.objects.create(label='Test Agenda Desk1', agenda=agenda) TimePeriod.objects.create( weekday=0, weekday_indexes=[1], start_time=datetime.time(10, 0), end_time=datetime.time(18, 0), desk=desk, ) TimePeriod.objects.create( weekday=0, weekday_indexes=[2], start_time=datetime.time(10, 0), end_time=datetime.time(18, 0), desk=desk, ) assert len(list(agenda.get_effective_time_periods())) == 2 def test_exception_read_only(): agenda = Agenda.objects.create(label='Agenda', kind='meetings') desk = Desk.objects.create(agenda=agenda, label='Desk') unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') source1 = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics') source2 = TimePeriodExceptionSource.objects.create( desk=desk, ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics'), ) source3 = TimePeriodExceptionSource.objects.create( desk=desk, settings_slug='slug', settings_label='Foo Bar', enabled=True ) exception = TimePeriodException.objects.create( desk=desk, start_datetime=now() - datetime.timedelta(days=2), end_datetime=now() - datetime.timedelta(days=1), ) assert exception.read_only is False for source in [source1, source2, source3]: exception.source = source exception.save() assert exception.read_only is True exception.source = None exception.desk = None exception.unavailability_calendar = unavailability_calendar exception.save() assert exception.read_only is True def test_desk_exceptions_within_two_weeks(): def set_prefetched_exceptions(desk): desk.prefetched_exceptions = TimePeriodException.objects.filter( Q(desk=desk) | Q(unavailability_calendar__desks=desk) ) agenda = Agenda.objects.create(label='Agenda', kind='meetings') desk = Desk.objects.create(agenda=agenda, label='Desk') # no exception set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [] # exception ends in the past exception = TimePeriodException.objects.create( desk=desk, start_datetime=now() - datetime.timedelta(days=2), end_datetime=now() - datetime.timedelta(days=1), ) set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [] # exception ends in the future - 14 days exception.end_datetime = now() + datetime.timedelta(days=10) # but starts in the past exception.start_datetime = now() - datetime.timedelta(days=2) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception] # exception ends in the future - 14 days exception.end_datetime = now() + datetime.timedelta(days=10) # but starts in the future exception.start_datetime = now() + datetime.timedelta(days=2) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception] # exception ends in the future + 14 days exception.end_datetime = now() + datetime.timedelta(days=20) # but starts in the past exception.start_datetime = now() - datetime.timedelta(days=2) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception] # create another one, very far way from now exception2 = TimePeriodException.objects.create( desk=desk, start_datetime=now() + datetime.timedelta(days=200), end_datetime=now() + datetime.timedelta(days=201), ) # exception ends in the future + 14 days exception.end_datetime = now() + datetime.timedelta(days=20) # but starts in the future - 14 days exception.start_datetime = now() + datetime.timedelta(days=2) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception] # exception ends in the future + 14 days exception.end_datetime = now() + datetime.timedelta(days=20) # but starts in the future + 14 days exception.start_datetime = now() + datetime.timedelta(days=21) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception] # exception in the past exception.end_datetime = now() - datetime.timedelta(days=20) exception.start_datetime = now() - datetime.timedelta(days=21) exception.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception2] # check ordering of the queryset: exception is after exception2 exception.start_datetime = now() + datetime.timedelta(days=10) exception.end_datetime = now() + datetime.timedelta(days=11) exception.save() exception2.start_datetime = now() + datetime.timedelta(days=5) exception2.end_datetime = now() + datetime.timedelta(days=6) exception2.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception2, exception] # add an exception from unavailability calendar unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') exception3 = TimePeriodException.objects.create( unavailability_calendar=unavailability_calendar, start_datetime=now() + datetime.timedelta(days=2), end_datetime=now() + datetime.timedelta(days=3), ) unavailability_calendar.desks.add(desk) set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception3, exception2, exception] # change it exception3.start_datetime = now() + datetime.timedelta(days=7) exception3.end_datetime = now() + datetime.timedelta(days=8) exception3.save() set_prefetched_exceptions(desk) assert list(desk.get_exceptions_within_two_weeks()) == [exception2, exception3, exception] def test_desk_duplicate(): agenda = Agenda.objects.create(label='Agenda') desk = Desk.objects.create(label='Desk', agenda=agenda) time_period = TimePeriod.objects.create( weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) ) TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics') source2 = TimePeriodExceptionSource.objects.create( desk=desk, ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics'), ) time_period_exception = TimePeriodException.objects.create( label='Exception', desk=desk, start_datetime=now() + datetime.timedelta(days=1), end_datetime=now() + datetime.timedelta(days=2), ) unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') unavailability_calendar.desks.add(desk) new_desk = desk.duplicate(label='New Desk') assert new_desk.pk != desk.pk assert new_desk.label == 'New Desk' assert new_desk.slug == 'new-desk' assert new_desk.timeperiod_set.count() == 1 new_time_period = TimePeriod.objects.get(desk=new_desk) assert new_time_period.weekday == time_period.weekday assert new_time_period.start_time == time_period.start_time assert new_time_period.end_time == time_period.end_time assert new_desk.timeperiodexception_set.count() == 1 new_time_period_exception = TimePeriodException.objects.get(desk=new_desk) assert new_time_period_exception.label == time_period_exception.label assert new_time_period_exception.start_datetime == time_period_exception.start_datetime assert new_time_period_exception.end_datetime == time_period_exception.end_datetime assert new_desk.timeperiodexceptionsource_set.count() == 2 assert TimePeriodExceptionSource.objects.filter( desk=new_desk, ics_url='http://example.com/sample.ics' ).exists() new_source2 = TimePeriodExceptionSource.objects.get(desk=new_desk, ics_filename='sample.ics') assert new_source2.ics_file.path != source2.ics_file.path assert new_desk.unavailability_calendars.count() == 1 assert new_desk.unavailability_calendars.get() == unavailability_calendar # duplicate again ! new_desk = desk.duplicate(label='New Desk') assert new_desk.slug == 'new-desk-1' def test_desk_duplicate_exception_sources(): agenda = Agenda.objects.create(label='Agenda') desk = Desk.objects.create(label='Desk', agenda=agenda) source = desk.timeperiodexceptionsource_set.create( ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE, name='sample.ics') ) source.refresh_timeperiod_exceptions_from_ics() assert TimePeriodException.objects.filter(desk=desk).count() == 2 new_desk = desk.duplicate(label='New Desk') new_source = new_desk.timeperiodexceptionsource_set.get(ics_filename='sample.ics') assert new_desk.timeperiodexception_set.count() == 2 source.delete() assert new_desk.timeperiodexception_set.count() == 2 new_source.delete() assert not new_desk.timeperiodexception_set.exists() @override_settings( EXCEPTIONS_SOURCES={ 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } ) def test_desk_duplicate_exception_source_from_settings(): agenda = build_agendas( '''\ meetings Agenda desk Desk ''' ) desk = agenda._desk desk.import_timeperiod_exceptions_from_settings(enable=True) source = desk.timeperiodexceptionsource_set.get(settings_slug='holidays') assert source.enabled exceptions_count = desk.timeperiodexception_set.count() new_desk = desk.duplicate(label='New Desk') assert new_desk.timeperiodexceptionsource_set.filter(settings_slug='holidays').count() == 1 assert new_desk.timeperiodexceptionsource_set.get(settings_slug='holidays').enabled assert new_desk.timeperiodexception_set.count() == exceptions_count source.disable() new_desk = desk.duplicate(label='New Desk') assert not new_desk.timeperiodexceptionsource_set.get(settings_slug='holidays').enabled assert not new_desk.timeperiodexception_set.exists() def test_agenda_meetings_duplicate(): group = Group(name='Group') group.save() agenda = Agenda.objects.create(label='Agenda', kind='meetings', view_role=group) desk = Desk.objects.create(label='Desk', agenda=agenda) meeting_type = MeetingType.objects.create(agenda=agenda, label='meeting', duration=30) time_period = TimePeriod.objects.create( weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0) ) TimePeriodExceptionSource.objects.create(desk=desk, ics_url='http://example.com/sample.ics') source2 = TimePeriodExceptionSource.objects.create( desk=desk, ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_DURATION, name='sample.ics'), ) time_period_exception = TimePeriodException.objects.create( label='Exception', desk=desk, start_datetime=now() + datetime.timedelta(days=1), end_datetime=now() + datetime.timedelta(days=2), ) resource = Resource.objects.create(label='Foo bar') agenda.resources.add(resource) new_agenda = agenda.duplicate() assert new_agenda.pk != agenda.pk assert new_agenda.label == 'Copy of Agenda' assert new_agenda.slug == 'copy-of-agenda' assert new_agenda.kind == 'meetings' assert new_agenda.view_role == group assert new_agenda.resources.first() == resource new_meeting_type = new_agenda.meetingtype_set.first() assert new_meeting_type.pk != meeting_type.pk assert new_meeting_type.label == meeting_type.label assert new_meeting_type.duration == meeting_type.duration assert new_meeting_type.slug == meeting_type.slug new_desk = new_agenda.desk_set.first() assert new_desk.pk != desk.pk assert new_desk.label == desk.label assert new_desk.slug == desk.slug assert new_desk.timeperiod_set.count() == 1 new_time_period = TimePeriod.objects.get(desk=new_desk) assert new_time_period.weekday == time_period.weekday assert new_time_period.start_time == time_period.start_time assert new_time_period.end_time == time_period.end_time assert new_desk.timeperiodexception_set.count() == 1 new_time_period_exception = TimePeriodException.objects.get(desk=new_desk) assert new_time_period_exception.label == time_period_exception.label assert new_time_period_exception.start_datetime == time_period_exception.start_datetime assert new_time_period_exception.end_datetime == time_period_exception.end_datetime assert new_desk.timeperiodexceptionsource_set.count() == 2 assert TimePeriodExceptionSource.objects.filter( desk=new_desk, ics_url='http://example.com/sample.ics' ).exists() new_source2 = TimePeriodExceptionSource.objects.get(desk=new_desk, ics_filename='sample.ics') assert new_source2.ics_file.path != source2.ics_file.path # duplicate again ! new_agenda = agenda.duplicate() assert new_agenda.slug == 'copy-of-agenda-1' @override_settings( EXCEPTIONS_SOURCES={ 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } ) def test_agenda_events_duplicate(): agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(days=1), duration=10, places=10, label='event', slug='event', ) desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') desk.import_timeperiod_exceptions_from_settings(enable=True) TimePeriodException.objects.create( desk=desk, start_datetime=now(), end_datetime=now() + datetime.timedelta(minutes=30), ) assert desk.timeperiodexception_set.count() == 34 AgendaNotificationsSettings.objects.create( agenda=agenda, full_event=AgendaNotificationsSettings.EMAIL_FIELD, full_event_emails=['hop@entrouvert.com', 'top@entrouvert.com'], ) AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1, email_extra_info='top') new_agenda = agenda.duplicate() assert new_agenda.pk != agenda.pk assert new_agenda.kind == 'events' assert new_agenda.notifications_settings.full_event == AgendaNotificationsSettings.EMAIL_FIELD assert new_agenda.notifications_settings.full_event_emails == ['hop@entrouvert.com', 'top@entrouvert.com'] assert new_agenda.reminder_settings.days_before_email == 1 assert new_agenda.reminder_settings.email_extra_info == 'top' new_desk = new_agenda.desk_set.get() assert new_desk.slug == '_exceptions_holder' assert new_desk.timeperiodexception_set.count() == 34 assert new_desk.timeperiodexception_set.filter(source__isnull=True).count() == 1 source = new_desk.timeperiodexceptionsource_set.get() assert source.enabled is True assert source.settings_slug == 'holidays' new_event = new_agenda.event_set.first() assert new_event.pk != event.pk assert new_event.label == event.label assert new_event.duration == event.duration assert new_event.duration == event.duration assert new_event.places == event.places assert new_event.start_datetime == event.start_datetime def test_agenda_events_recurrence_duplicate(freezer): freezer.move_to('2021-01-06 12:00') # Wednesday now_ = now() end = now_ + datetime.timedelta(days=15) orig_agenda = Agenda.objects.create(label='Agenda', kind='events') Desk.objects.create(agenda=orig_agenda, slug='_exceptions_holder') event = Event.objects.create( agenda=orig_agenda, start_datetime=now_, recurrence_days=[now_.isoweekday()], label='Event', places=10, recurrence_end_date=end, ) event.create_all_recurrences() dup_agenda = orig_agenda.duplicate() for agenda in (orig_agenda, dup_agenda): assert agenda.event_set.count() == 4 # one recurring event, 3 real events assert agenda.event_set.filter(recurrence_days__isnull=False).count() == 1 rec_event = agenda.event_set.filter(recurrence_days__isnull=False).first() for event in agenda.event_set.filter(recurrence_days__isnull=True): assert event.primary_event == rec_event def test_agenda_virtual_duplicate(): agenda1 = Agenda.objects.create(label='Agenda 1', kind='meetings') agenda2 = Agenda.objects.create(label='Agenda 2', kind='meetings') virt_agenda = Agenda.objects.create(label='Virtual agenda', kind='virtual') VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1) VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2) virt_agenda.save() excluded_timeperiod = TimePeriod.objects.create( weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), agenda=virt_agenda ) new_agenda = virt_agenda.duplicate() assert new_agenda.pk != virt_agenda.pk assert new_agenda.kind == 'virtual' new_time_period = new_agenda.excluded_timeperiods.first() assert new_time_period.pk != excluded_timeperiod.pk assert new_time_period.agenda == new_agenda assert new_time_period.weekday == excluded_timeperiod.weekday assert new_time_period.start_time == excluded_timeperiod.start_time assert new_time_period.end_time == excluded_timeperiod.end_time assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda1).exists() assert VirtualMember.objects.filter(virtual_agenda=new_agenda, real_agenda=agenda2).exists() def test_agendas_cancel_events_command(): agenda = Agenda.objects.create(label='Events', kind='events') event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') for _ in range(5): Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/') event.cancellation_scheduled = True event.save() with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('cancel_events') assert mock_send.call_count == 5 assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 5 event.refresh_from_db() assert not event.cancellation_scheduled assert event.cancelled def test_agendas_cancel_events_command_network_error(freezer): freezer.move_to('2020-01-01') agenda = Agenda.objects.create(label='Events', kind='events') event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') def mocked_requests_connection_error(request, **kwargs): if 'good' in request.url: return mock.Mock(status_code=200) raise requests.exceptions.ConnectionError('unreachable') booking_good_url = Booking.objects.create(event=event, cancel_callback_url='http://good.org/') for _ in range(5): Booking.objects.create(event=event, cancel_callback_url='http://example.org/jump/trigger/') event.cancellation_scheduled = True event.save() with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response mock_send.side_effect = mocked_requests_connection_error call_command('cancel_events') assert mock_send.call_count == 6 booking_good_url.refresh_from_db() assert booking_good_url.cancellation_datetime assert Booking.objects.filter(cancellation_datetime__isnull=True).count() == 5 event.refresh_from_db() assert not event.cancellation_scheduled assert not event.cancelled report = EventCancellationReport.objects.get(event=event) assert report.bookings.count() == 5 assert len(report.booking_errors) == 5 for booking in report.bookings.all(): assert report.booking_errors[str(booking.pk)] == 'unreachable' # old reports are automatically removed freezer.move_to('2020-03-01') call_command('cancel_events') assert not EventCancellationReport.objects.exists() @mock.patch('django.contrib.auth.models.Group.role', create=True) @pytest.mark.parametrize( 'emails_to_members,emails', [ (False, []), (False, ['test@entrouvert.com']), (True, []), (True, ['test@entrouvert.com']), ], ) def test_agenda_notifications_role_email(mocked_role, emails_to_members, emails, mailoutbox): group = Group.objects.create(name='group') user = User.objects.create(username='user', email='user@entrouvert.com') user.groups.add(group) mocked_role.emails_to_members = emails_to_members mocked_role.emails = emails expected_recipients = emails if emails_to_members: expected_recipients.append(user.email) expected_email_count = 1 if emails else 0 agenda = Agenda.objects.create(label='Foo bar', kind='event', edit_role=group) event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') settings = AgendaNotificationsSettings.objects.create(agenda=agenda) settings.almost_full_event = AgendaNotificationsSettings.EDIT_ROLE settings.save() # book 9/10 places to reach almost full state for _ in range(9): Booking.objects.create(event=event) event.refresh_from_db() assert event.almost_full call_command('send_email_notifications') assert len(mailoutbox) == expected_email_count if mailoutbox: assert mailoutbox[0].bcc == expected_recipients for addr in expected_recipients: assert addr not in mailoutbox[0].to assert addr in mailoutbox[0].recipients() assert len(mailoutbox[0].recipients()) == len(expected_recipients) + 1 assert mailoutbox[0].subject == 'Alert: event "Hop" is almost full (90%)' assert 'manage/agendas/%s/events/%s/' % (agenda.id, event.id) in mailoutbox[0].body assert 'manage/agendas/%s/events/%s/' % (agenda.id, event.id) in mailoutbox[0].alternatives[0][0] # no new email on subsequent run call_command('send_email_notifications') assert len(mailoutbox) == expected_email_count def test_agenda_notifications_email_list(mailoutbox): agenda = Agenda.objects.create(label='Foo bar', kind='event') event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') settings = AgendaNotificationsSettings.objects.create(agenda=agenda) settings.full_event = AgendaNotificationsSettings.EMAIL_FIELD settings.full_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com'] settings.save() for _ in range(10): Booking.objects.create(event=event) event.refresh_from_db() assert event.full call_command('send_email_notifications') assert len(mailoutbox) == 1 assert mailoutbox[0].bcc == recipients for addr in recipients: assert addr not in mailoutbox[0].to assert addr in mailoutbox[0].recipients() assert len(mailoutbox[0].recipients()) == len(recipients) + 1 assert mailoutbox[0].subject == 'Alert: event "Hop" is full' assert ( 'view it here: https://example.com/manage/agendas/%s/events/%s/' % ( agenda.pk, event.pk, ) in mailoutbox[0].body ) html, mime = mailoutbox[0].alternatives[0] assert mime == 'text/html' assert '' % (agenda.pk, event.pk) in html # no new email on subsequent run call_command('send_email_notifications') assert len(mailoutbox) == 1 def test_agenda_notifications_cancelled(mailoutbox): agenda = Agenda.objects.create(label='Foo bar', kind='event') event = Event.objects.create(agenda=agenda, places=10, start_datetime=now(), label='Hop') settings = AgendaNotificationsSettings.objects.create(agenda=agenda) settings.cancelled_event = AgendaNotificationsSettings.EMAIL_FIELD settings.cancelled_event_emails = recipients = ['hop@entrouvert.com', 'top@entrouvert.com'] settings.save() event.cancelled = True event.save() call_command('send_email_notifications') assert len(mailoutbox) == 1 assert mailoutbox[0].bcc == recipients for addr in recipients: assert addr not in mailoutbox[0].to assert addr in mailoutbox[0].recipients() assert len(mailoutbox[0].recipients()) == len(recipients) + 1 assert mailoutbox[0].subject == 'Alert: event "Hop" is cancelled' # no new email on subsequent run call_command('send_email_notifications') assert len(mailoutbox) == 1 def test_agenda_reminders(mailoutbox, freezer): agenda = Agenda.objects.create(label='Events', kind='events') # add some old event with booking freezer.move_to('2019-01-01') old_event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') Booking.objects.create(event=old_event, user_email='old@test.org') # no reminder configured call_command('send_booking_reminders') assert len(mailoutbox) == 0 # move to present day freezer.move_to('2020-01-01 14:00') # configure reminder the day before AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1) # event starts in 2 days start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') for _ in range(5): booking = Booking.objects.create(event=event, user_email='t@test.org') # extra booking with no email, should be ignored Booking.objects.create(event=event) # secondary booking, should be ignored Booking.objects.create(event=event, user_email='t@test.org', primary_booking=booking) freezer.move_to('2020-01-02 10:00') # not time to send reminders yet call_command('send_booking_reminders') assert len(mailoutbox) == 0 # one of the booking is cancelled Booking.objects.filter(user_email='t@test.org').first().cancel() freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') assert len(mailoutbox) == 4 mailoutbox.clear() call_command('send_booking_reminders') assert len(mailoutbox) == 0 # booking is placed the day of the event, notfication should no be sent freezer.move_to('2020-01-03 08:00') Booking.objects.create(event=event, user_email='t@test.org') call_command('send_booking_reminders') assert len(mailoutbox) == 0 @pytest.mark.freeze_time('2020-01-01 14:00') def test_agenda_reminders_extra_emails(mailoutbox, freezer): agenda = Agenda.objects.create(label='Events', kind='events') AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') Booking.objects.create( event=event, user_email='t@test.org', extra_emails=['t@test.org', 'u@test.org', 'v@test.org'], ) Booking.objects.create(event=event, extra_emails=['w@test.org']) freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') assert len(mailoutbox) == 4 assert all(len(x.to) == 1 for x in mailoutbox) assert {x.to[0] for x in mailoutbox} == {'t@test.org', 'u@test.org', 'v@test.org', 'w@test.org'} @override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO') def test_agenda_reminders_sms(freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Events', kind='events') AgendaReminderSettings.objects.create(agenda=agenda, days_before_sms=1) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') for _ in range(5): Booking.objects.create(event=event, user_phone_number='+336123456789') Booking.objects.create(event=event) Booking.objects.create( event=event, user_phone_number='+33111111111', extra_phone_numbers=['+33111111111', '+33222222222', '+33333333333'], ) Booking.objects.create(event=event, extra_phone_numbers=['+336123456789']) freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') assert mock_send.call_count == 7 body = json.loads(mock_send.call_args_list[0][0][0].body.decode()) assert body['from'] == 'EO' assert body['to'] == ['+336123456789'] body = json.loads(mock_send.call_args_list[5][0][0].body.decode()) assert body['from'] == 'EO' assert set(body['to']) == {'+33111111111', '+33222222222', '+33333333333'} body = json.loads(mock_send.call_args_list[6][0][0].body.decode()) assert body['from'] == 'EO' assert set(body['to']) == {'+336123456789'} @override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO') def test_agenda_reminders_retry(freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Events', kind='events') settings = AgendaReminderSettings.objects.create(agenda=agenda) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') settings.days_before_email = 1 settings.save() booking = Booking.objects.create(event=event, user_email='t@test.org') freezer.move_to('2020-01-02 15:00') def send_mail_error(*args, **kwargs): raise smtplib.SMTPException with mock.patch('chrono.agendas.management.commands.utils.send_mail') as mock_send: mock_send.return_value = None mock_send.side_effect = send_mail_error call_command('send_booking_reminders') assert mock_send.call_count == 1 booking.refresh_from_db() assert not booking.email_reminder_datetime assert not booking.sms_reminder_datetime mock_send.side_effect = None call_command('send_booking_reminders') assert mock_send.call_count == 2 booking.refresh_from_db() assert booking.email_reminder_datetime assert not booking.sms_reminder_datetime settings.days_before_email = None settings.days_before_sms = 1 settings.save() freezer.move_to('2020-01-01 14:00') booking = Booking.objects.create(event=event, user_phone_number='+336123456789') freezer.move_to('2020-01-02 15:00') def mocked_requests_connection_error(*args, **kwargs): raise requests.ConnectionError('unreachable') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_send.side_effect = mocked_requests_connection_error mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') assert mock_send.call_count == 1 booking.refresh_from_db() assert not booking.sms_reminder_datetime assert not booking.email_reminder_datetime mock_send.side_effect = None call_command('send_booking_reminders') assert mock_send.call_count == 2 booking.refresh_from_db() assert booking.sms_reminder_datetime assert not booking.email_reminder_datetime settings.days_before_email = 1 settings.save() freezer.move_to('2020-01-01 14:00') booking = Booking.objects.create(event=event, user_phone_number='+336123456789', user_email='t@test.org') freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send, mock.patch( 'chrono.agendas.management.commands.utils.send_mail' ) as mock_send_mail: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response mock_send_mail.return_value = None call_command('send_booking_reminders') assert mock_send.call_count == 1 assert mock_send_mail.call_count == 1 booking.refresh_from_db() assert booking.sms_reminder_datetime assert booking.email_reminder_datetime call_command('send_booking_reminders') assert mock_send.call_count == 1 assert mock_send_mail.call_count == 1 @override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO') def test_agenda_reminders_different_days_before(freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Events', kind='events') AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1, days_before_sms=2) start_datetime = now() + datetime.timedelta(days=3) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') Booking.objects.create(event=event, user_email='t@test.org', user_phone_number='+336123456789') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send_sms, mock.patch( 'chrono.agendas.management.commands.utils.send_mail' ) as mock_send_mail: mock_response = mock.Mock(status_code=200) mock_send_sms.return_value = mock_response mock_send_mail.return_value = None call_command('send_booking_reminders') assert mock_send_sms.call_count == 0 assert mock_send_mail.call_count == 0 # two days before freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') assert mock_send_sms.call_count == 1 assert mock_send_mail.call_count == 0 # one day before freezer.move_to('2020-01-03 15:00') call_command('send_booking_reminders') assert mock_send_sms.call_count == 1 assert mock_send_mail.call_count == 1 @override_settings(TIME_ZONE='UTC') def test_agenda_reminders_email_content(mailoutbox, freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Events', kind='events') AgendaReminderSettings.objects.create( agenda=agenda, days_before_email=1, email_extra_info='Do no forget ID card.' ) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create( agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party', description='Come !', url='https://example.party', pricing='10€', ) Booking.objects.create(event=event, user_email='t@test.org') freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') mail = mailoutbox[0] assert mail.subject == 'Reminder for your booking tomorrow at 2 p.m.' mail_bodies = (mail.body, mail.alternatives[0][0]) for body in mail_bodies: assert 'Hi,' in body assert 'You have booked event "Pool party", on Friday 3 January at 2 p.m..' in body assert 'Do no forget ID card.' in body assert 'Come !' in body assert 'Pricing: 10€' in body assert 'cancel' not in body assert 'if present' not in body # assert separation with preview code assert 'More information: https://example.party' in mail_bodies[0] assert 'More information' in mail_bodies[1] mailoutbox.clear() freezer.move_to('2020-01-01 14:00') Booking.objects.create(event=event, user_email='t@test.org', form_url='https://example.org/') freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') mail = mailoutbox[0] assert 'If in need to cancel it, you can do so here: https://example.org/' in mail.body assert 'Edit or cancel booking' in mail.alternatives[0][0] assert 'href="https://example.org/"' in mail.alternatives[0][0] # check url translation Booking.objects.all().delete() mailoutbox.clear() freezer.move_to('2020-01-01 14:00') Booking.objects.create(event=event, user_email='t@test.org', form_url='publik://default/someform/1/') freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') mail = mailoutbox[0] assert 'If in need to cancel it, you can do so here: http://example.org/someform/1/' in mail.body assert 'Edit or cancel booking' in mail.alternatives[0][0] assert 'href="http://example.org/someform/1/"' in mail.alternatives[0][0] @override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO', TIME_ZONE='UTC') def test_agenda_reminders_sms_content(freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Events', kind='events') AgendaReminderSettings.objects.create( agenda=agenda, days_before_sms=1, sms_extra_info='Do no forget ID card.' ) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') Booking.objects.create(event=event, user_phone_number='+336123456789') freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') body = json.loads(mock_send.call_args[0][0].body.decode()) assert ( body['message'] == 'Reminder: you have booked event "Pool party", on 03/01 at 2 p.m.. Do no forget ID card.' ) @override_settings(SMS_URL='https://passerelle.test.org/sms/send/', SMS_SENDER='EO', TIME_ZONE='UTC') def test_agenda_reminders_templated_content(mailoutbox, freezer): freezer.move_to('2020-01-01 14:00') agenda = Agenda.objects.create(label='Main Center', kind='events') AgendaReminderSettings.objects.create( agenda=agenda, days_before_email=1, days_before_sms=1, email_extra_info='Go to {{ booking.event.agenda.label }}.\nTake your {{ booking.extra_data.document_type }}.', sms_extra_info='Take your {{ booking.extra_data.document_type }}.', ) start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Pool party') Booking.objects.create( event=event, user_email='t@test.org', user_phone_number='+336123456789', extra_data={'document_type': '"receipt"'}, ) freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') mail = mailoutbox[0] assert 'Go to Main Center.\nTake your "receipt".' in mail.body assert '

Go to Main Center.
Take your "receipt".

' in mail.alternatives[0][0] body = json.loads(mock_send.call_args[0][0].body.decode()) assert 'Take your "receipt".' in body['message'] # in case of invalid template, send anyway freezer.move_to('2020-01-01 14:00') Booking.objects.create(event=event, user_email='t@test.org', user_phone_number='+336123456789') agenda.reminder_settings.email_extra_info = 'Take your {{ syntax error }}' agenda.reminder_settings.sms_extra_info = 'Take your {{ syntax error }}' agenda.reminder_settings.save() freezer.move_to('2020-01-02 15:00') with mock.patch('chrono.utils.requests_wrapper.RequestsSession.send') as mock_send: mock_response = mock.Mock(status_code=200) mock_send.return_value = mock_response call_command('send_booking_reminders') assert len(mailoutbox) == 2 assert 'Take your' not in mailoutbox[1].body body = json.loads(mock_send.call_args[0][0].body.decode()) assert 'Take your' not in body['message'] @override_settings(TIME_ZONE='UTC') def test_agenda_reminders_meetings(mailoutbox, freezer): freezer.move_to(utc('2020-01-01 11:00')) agenda = build_agendas( '''\ meetings Events desk Desk timeperiod wednesday 10:00-18:00 meeting-type Bar 30 reminder-setting days_before_email=2 ''' ) add_meeting( agenda, start_datetime=utc('2020-01-06T11:00:00'), user_email='t@test.org', user_display_label='Birth certificate', form_url='publik://default/someform/1/', ) freezer.move_to(utc('2020-01-04 15:00')) call_command('send_booking_reminders') assert len(mailoutbox) == 1 mail = mailoutbox[0] assert mail.subject == 'Reminder for your meeting in 2 days at 11 a.m.' assert 'Your meeting "Birth certificate" is scheduled on Monday 6 January at 11 a.m..' in mail.body assert 'If in need to cancel it, you can do so here: http://example.org/someform/1/' in mail.body assert 'href="http://example.org/someform/1/"' in mail.alternatives[0][0] def test_agenda_reminders_waiting_list(mailoutbox, freezer): agenda = Agenda.objects.create(label='Events', kind='events') freezer.move_to('2020-01-01 14:00') # configure reminder the day before AgendaReminderSettings.objects.create(agenda=agenda, days_before_email=1) # event starts in 2 days start_datetime = now() + datetime.timedelta(days=2) event = Event.objects.create(agenda=agenda, start_datetime=start_datetime, places=10, label='Event') for _ in range(5): Booking.objects.create(event=event, user_email='t@test.org') # extra booking in waiting list, should be ignored Booking.objects.create(event=event, user_email='t@test.org', in_waiting_list=True) freezer.move_to('2020-01-02 15:00') call_command('send_booking_reminders') assert len(mailoutbox) == 5 mailoutbox.clear() def test_anonymize_bookings(freezer): day = datetime.datetime(year=2020, month=1, day=1) freezer.move_to(day) agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(days=10), places=10, label='Event' ) for _ in range(5): Booking.objects.create( event=event, extra_data={'test': True}, label='john', user_display_label='john', user_external_id='john', user_first_name='john', user_last_name='doe', backoffice_url='https://example.org', ) freezer.move_to(day + datetime.timedelta(days=50)) new_event = Event.objects.create(agenda=agenda, start_datetime=now(), places=10, label='Event') booking = Booking.objects.create(event=new_event, label='hop') freezer.move_to(day + datetime.timedelta(days=101)) call_command('anonymize_bookings') assert not Booking.objects.filter(anonymization_datetime__isnull=False).exists() # now ask for anonymization agenda.anonymize_delay = 100 agenda.save() call_command('anonymize_bookings') # bookings were placed more than 100 days ago but event was only 90 days ago assert not Booking.objects.filter(anonymization_datetime__isnull=False).exists() freezer.move_to(day + datetime.timedelta(days=111)) call_command('anonymize_bookings') assert ( Booking.objects.filter( label='', user_display_label='', user_external_id='', user_first_name='', user_last_name='', backoffice_url='https://example.org', extra_data={}, anonymization_datetime=now(), ).count() == 5 ) booking.refresh_from_db() assert booking.label == 'hop' assert not booking.anonymization_datetime def test_recurring_events(freezer): freezer.move_to('2021-01-06 12:00') # Wednesday agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=[now().isoweekday()], label='Event', places=10, waiting_list_places=10, duration=10, description='Description', url='https://example.com', pricing='10€', ) event.refresh_from_db() recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=15)) assert len(recurrences) == 3 first_event = recurrences[0] assert first_event.slug == event.slug + '--2021-01-06-1300' event_json = event.export_json() first_event_json = first_event.export_json() different_fields = ['slug', 'recurrence_days', 'recurrence_week_interval'] assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields) second_event = recurrences[1] assert second_event.start_datetime == first_event.start_datetime + datetime.timedelta(days=7) assert second_event.start_datetime.weekday() == first_event.start_datetime.weekday() assert second_event.slug == 'event--2021-01-13-1300' different_fields = ['slug', 'start_datetime'] second_event_json = second_event.export_json() assert all(first_event_json[k] == second_event_json[k] for k in event_json if k not in different_fields) new_recurrences = event.get_recurrences( localtime() + datetime.timedelta(days=15), localtime() + datetime.timedelta(days=30), ) assert len(recurrences) == 3 assert new_recurrences[0].start_datetime == recurrences[-1].start_datetime + datetime.timedelta(days=7) def test_recurring_events_dst(freezer, settings): freezer.move_to('2020-10-24 12:00') settings.TIME_ZONE = 'Europe/Brussels' agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=[now().isoweekday()], places=5 ) event.refresh_from_db() dt = localtime() recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8)) event_before_dst, event_after_dst = recurrences assert event_before_dst.start_datetime.hour + 1 == event_after_dst.start_datetime.hour assert event_before_dst.slug == 'agenda-event--2020-10-24-1400' assert event_after_dst.slug == 'agenda-event--2020-10-31-1400' freezer.move_to('2020-11-24 12:00') new_recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8)) new_event_before_dst, new_event_after_dst = new_recurrences assert event_before_dst.start_datetime == new_event_before_dst.start_datetime assert event_after_dst.start_datetime == new_event_after_dst.start_datetime assert event_before_dst.slug == new_event_before_dst.slug assert event_after_dst.slug == new_event_after_dst.slug def test_recurring_events_repetition(freezer): freezer.move_to('2021-01-06 12:00') # Wednesday agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), # everyday places=5, ) event.refresh_from_db() start_datetime = localtime(event.start_datetime) freezer.move_to('2021-01-06 12:01') # recurrence on same day should not be returned recurrences = event.get_recurrences( localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) ) assert len(recurrences) == 6 assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) for i in range(len(recurrences) - 1): assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime event.recurrence_days = list(range(1, 6)) # from Monday to Friday event.save() recurrences = event.get_recurrences( localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) ) assert len(recurrences) == 4 assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5) assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) event.recurrence_days = [localtime(event.start_datetime).isoweekday()] event.recurrence_week_interval = 2 event.save() recurrences = event.get_recurrences( localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45) ) assert len(recurrences) == 3 assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=14) assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=14) * len(recurrences) for i in range(len(recurrences) - 1): assert ( recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime ) event.recurrence_days = [4] # Tuesday but start_datetime is a Wednesday event.recurrence_week_interval = 1 event.save() recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=10)) assert len(recurrences) == 2 # no recurrence exist on Wednesday assert all(localtime(r.start_datetime).weekday() == 3 for r in recurrences) @pytest.mark.freeze_time('2021-01-06') def test_recurring_events_with_end_date(): agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=(now() + datetime.timedelta(days=5)).date(), ) event.refresh_from_db() start_datetime = localtime(event.start_datetime) recurrences = event.get_recurrences( localtime(event.start_datetime), localtime(event.start_datetime) + datetime.timedelta(days=10) ) assert len(recurrences) == 5 assert recurrences[0].start_datetime == start_datetime assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=4) @override_settings( EXCEPTIONS_SOURCES={ 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, } ) def test_recurring_events_exceptions(freezer): freezer.move_to('2021-05-01 12:00') agenda = Agenda.objects.create(label='Agenda', kind='events') desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') desk.import_timeperiod_exceptions_from_settings() event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), places=5, ) recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01' desk.import_timeperiod_exceptions_from_settings(enable=True) recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) # 05-01 is a holiday assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' first_event = recurrences[0] # exception before first_event start_datetime time_period_exception = TimePeriodException.objects.create( desk=desk, start_datetime=first_event.start_datetime - datetime.timedelta(hours=1), end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30), ) recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' # exception wraps around first_event start_datetime time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30) time_period_exception.save() recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03' # exception starts after first_event start_datetime time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15) time_period_exception.save() recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' # exception spans multiple days time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3) time_period_exception.save() recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' # move exception to unavailability calendar unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') time_period_exception.desk = None time_period_exception.unavailability_calendar = unavailability_calendar time_period_exception.save() recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' unavailability_calendar.desks.add(desk) recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' def test_recurring_events_exceptions_update_recurrences(freezer): freezer.move_to('2021-05-01 12:00') agenda = Agenda.objects.create(label='Agenda', kind='events') desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') daily_event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=datetime.date(year=2021, month=5, day=8), ) weekly_event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=[now().isoweekday()], places=5, recurrence_end_date=datetime.date(year=2021, month=6, day=1), ) weekly_event_no_end_date = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(hours=2), recurrence_days=[now().isoweekday()], places=5, ) Event.create_events_recurrences([daily_event, weekly_event, weekly_event_no_end_date]) assert Event.objects.filter(primary_event=daily_event).count() == 7 assert Event.objects.filter(primary_event=weekly_event).count() == 5 assert Event.objects.filter(primary_event=weekly_event_no_end_date).count() == 53 time_period_exception = TimePeriodException.objects.create( desk=desk, start_datetime=datetime.date(year=2021, month=5, day=5), end_datetime=datetime.date(year=2021, month=5, day=10), ) agenda.update_event_recurrences() assert Event.objects.filter(primary_event=daily_event).count() == 4 assert Event.objects.filter(primary_event=weekly_event).count() == 4 assert Event.objects.filter(primary_event=weekly_event_no_end_date).count() == 52 time_period_exception.delete() agenda.update_event_recurrences() assert Event.objects.filter(primary_event=daily_event).count() == 7 assert Event.objects.filter(primary_event=weekly_event).count() == 5 assert Event.objects.filter(primary_event=weekly_event_no_end_date).count() == 53 event = Event.objects.get( primary_event=weekly_event_no_end_date, start_datetime=now() + datetime.timedelta(days=7, hours=2) ) Booking.objects.create(event=event) time_period_exception.save() agenda.update_event_recurrences() assert Booking.objects.count() == 1 assert Event.objects.filter(primary_event=weekly_event_no_end_date).count() == 53 assert agenda.recurrence_exceptions_report.events.get() == event # no recurrence end date means new events are created as time moves on freezer.move_to('2021-06-01 12:00') agenda.update_event_recurrences() assert Event.objects.filter(primary_event=weekly_event_no_end_date).count() == 57 def test_recurring_events_exceptions_update_recurrences_start_datetime_modified(freezer): freezer.move_to('2021-09-06 12:00') # Monday agenda = Agenda.objects.create(label='Agenda', kind='events') desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') daily_event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=datetime.date(year=2021, month=9, day=13), ) daily_event.create_all_recurrences() assert daily_event.recurrences.count() == 7 monday_recurrence = daily_event.recurrences.get(start_datetime__day=6) monday_recurrence.start_datetime += datetime.timedelta(days=7) monday_recurrence.save() sunday_recurrence = daily_event.recurrences.get(start_datetime__day=12) sunday_recurrence.start_datetime += datetime.timedelta(hours=1) sunday_recurrence.save() # exception from Monday 06/09 to Tuesday 07/09 TimePeriodException.objects.create( desk=desk, start_datetime=datetime.date(year=2021, month=9, day=6), end_datetime=datetime.date(year=2021, month=9, day=8), ) # exception on Sunday 12/09 TimePeriodException.objects.create( desk=desk, start_datetime=datetime.date(year=2021, month=9, day=12), end_datetime=datetime.date(year=2021, month=9, day=13), ) agenda.update_event_recurrences() # Tuesday event was deleted assert daily_event.recurrences.count() == 6 assert not Event.objects.filter(start_datetime__day=7).exists() # Monday and Sunday still exist assert Event.objects.filter(pk__in=[monday_recurrence.pk, sunday_recurrence.pk]).exists() @pytest.mark.freeze_time('2022-02-22 14:00') def test_recurring_events_update_recurrences_new_event(freezer): agenda = Agenda.objects.create(label='Agenda', kind='events') Desk.objects.create(agenda=agenda, slug='_exceptions_holder') daily_event = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=now() + datetime.timedelta(days=7), ) agenda.update_event_recurrences() assert daily_event.recurrences.count() == 7 Event.objects.all().delete() daily_event_no_end_date = Event.objects.create( agenda=agenda, start_datetime=now(), recurrence_days=[2], recurrence_week_interval=3, places=5, ) agenda.update_event_recurrences() assert daily_event_no_end_date.recurrences.count() == 18 daily_event = Event.objects.create( agenda=agenda, start_datetime=now().replace(hour=10), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=now() + datetime.timedelta(days=7), ) agenda.update_event_recurrences() assert daily_event.recurrences.count() == 7 assert daily_event_no_end_date.recurrences.count() == 18 def test_recurring_events_display(freezer): freezer.move_to('2021-01-06 12:30') agenda = Agenda.objects.create(label='Agenda', kind='events') event = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(days=1), recurrence_days=list(range(1, 8)), places=5, ) assert event.get_recurrence_display() == 'Daily at 1:30 p.m., from Jan. 7, 2021' freezer.move_to('2021-01-07 12:30') assert event.get_recurrence_display() == 'Daily at 1:30 p.m.' event.recurrence_days = [2, 3, 4, 5] event.save() assert event.get_recurrence_display() == 'From Tuesday to Friday at 1:30 p.m.' event.recurrence_days = [5, 6, 7] event.save() assert event.get_recurrence_display() == 'From Friday to Sunday at 1:30 p.m.' event.recurrence_days = [2, 5, 7] event.save() assert event.get_recurrence_display() == 'On Tuesdays, Fridays, Sundays at 1:30 p.m.' event.recurrence_days = [1] event.recurrence_week_interval = 2 event.save() assert event.get_recurrence_display() == 'On Mondays at 1:30 p.m., once every two weeks' event.recurrence_week_interval = 3 event.recurrence_end_date = now() + datetime.timedelta(days=7) event.save() assert ( event.get_recurrence_display() == 'On Mondays at 1:30 p.m., once every three weeks, until Jan. 14, 2021' ) freezer.move_to('2021-01-06 12:30') assert ( event.get_recurrence_display() == 'On Mondays at 1:30 p.m., once every three weeks, from Jan. 7, 2021, until Jan. 14, 2021' ) @pytest.mark.parametrize('partial_bookings', [True, False]) def test_event_triggered_fields(partial_bookings): full_flags_true_value = not (partial_bookings) # alter event pk sequence to have a bigint with connection.cursor() as cursor: cursor.execute("SELECT nextval('agendas_event_id_seq')") row = cursor.fetchone() if row[0] < 2**31: cursor.execute('ALTER SEQUENCE agendas_event_id_seq RESTART WITH %s;' % 2**31) agenda = Agenda.objects.create(label='Agenda', kind='events', partial_bookings=partial_bookings) event = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(days=10), places=10, label='Event' ) event2 = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(days=10), places=10, label='Event' ) assert event.booked_places == 0 assert event.booked_waiting_list_places == 0 assert event.almost_full is False assert event.full is False event.booked_places = 42 event.booked_waiting_list_places = 42 event.almost_full = True event.full = True event.save() # computed by triggers event.refresh_from_db() assert event.booked_places == 0 assert event.booked_waiting_list_places == 0 assert event.almost_full is False assert event.full is False # add bookings for other event: no impact for _ in range(10): Booking.objects.create(event=event2) event.refresh_from_db() assert event.booked_places == 0 assert event.booked_waiting_list_places == 0 assert event.almost_full is False assert event.full is False # add bookings for _ in range(9): Booking.objects.create(event=event) event.refresh_from_db() assert event.booked_places == 9 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full is False Booking.objects.create(event=event) event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # cancel bookings for other event: no impact event2.booking_set.filter(cancellation_datetime__isnull=True).first().cancel() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # cancel bookings event.booking_set.filter(cancellation_datetime__isnull=True).first().cancel() event.refresh_from_db() assert event.booked_places == 9 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full is False event.booking_set.filter(cancellation_datetime__isnull=True).first().cancel() event.refresh_from_db() assert event.booked_places == 8 assert event.booked_waiting_list_places == 0 assert event.almost_full is False assert event.full is False # update places event.places = 20 event.save() event.refresh_from_db() assert event.booked_places == 8 assert event.booked_waiting_list_places == 0 assert event.almost_full is False assert event.full is False Booking.objects.create(event=event) Booking.objects.create(event=event) event.places = 10 event.save() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # with a waiting list event.waiting_list_places = 5 event.save() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full is False # add bookings for other event: no impact for _ in range(10): Booking.objects.create(event=event2, in_waiting_list=True) event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 0 assert event.almost_full == full_flags_true_value assert event.full is False # add bookings Booking.objects.create(event=event, in_waiting_list=True) event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 1 assert event.almost_full == full_flags_true_value assert event.full is False for _ in range(1, 5): Booking.objects.create(event=event, in_waiting_list=True) event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 5 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # cancel bookings for other event: no impact event2.booking_set.filter(in_waiting_list=True, cancellation_datetime__isnull=True).first().cancel() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 5 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # cancel bookings event.booking_set.filter(in_waiting_list=True, cancellation_datetime__isnull=True).first().cancel() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 4 assert event.almost_full == full_flags_true_value assert event.full is False # update waiting list places event.waiting_list_places = 4 event.save() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 4 assert event.almost_full == full_flags_true_value assert event.full == full_flags_true_value # delete bookings event.booking_set.filter(in_waiting_list=True, cancellation_datetime__isnull=True).first().delete() event.refresh_from_db() assert event.booked_places == 10 assert event.booked_waiting_list_places == 3 assert event.almost_full == full_flags_true_value assert event.full is False event.booking_set.filter(in_waiting_list=False, cancellation_datetime__isnull=True).first().delete() event.refresh_from_db() assert event.booked_places == 9 assert event.booked_waiting_list_places == 3 assert event.almost_full == full_flags_true_value assert event.full is False event.booking_set.filter(in_waiting_list=False, cancellation_datetime__isnull=True).first().delete() event.refresh_from_db() assert event.booked_places == 8 assert event.booked_waiting_list_places == 3 assert event.almost_full is False assert event.full is False def test_recurring_events_create_past_recurrences(freezer): freezer.move_to('2021-09-06 12:00') # Monday agenda = Agenda.objects.create(label='Agenda', kind='events') Desk.objects.create(agenda=agenda, slug='_exceptions_holder') daily_event = Event.objects.create( agenda=agenda, start_datetime=now() - datetime.timedelta(days=3), recurrence_days=list(range(1, 8)), places=5, recurrence_end_date=now() + datetime.timedelta(days=3), ) daily_event.create_all_recurrences() assert daily_event.recurrences.count() == 6 @pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday of 8th week def test_shared_custody_agenda(): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='even', guardian=father) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='odd', guardian=mother) slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=30)) assert [x.date for x in slots] == [now().date() + datetime.timedelta(days=i) for i in range(30)] assert all(x.guardian == father for x in slots if x.date.isocalendar()[1] % 2 == 0) assert all(x.guardian == mother for x in slots if x.date.isocalendar()[1] % 2 == 1) # add mother custody period on father's week, on 23/02 and 24/02 SharedCustodyPeriod.objects.create( agenda=agenda, guardian=mother, date_start=datetime.date(year=2022, month=2, day=23), date_end=datetime.date(year=2022, month=2, day=25), ) slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=5)) slots = [(x.date.strftime('%d/%m'), str(x.guardian)) for x in slots] assert slots == [ ('22/02', 'John Doe'), ('23/02', 'Jane Doe'), ('24/02', 'Jane Doe'), ('25/02', 'John Doe'), ('26/02', 'John Doe'), ] # add father custody period on father's week, nothing should change SharedCustodyPeriod.objects.create( agenda=agenda, guardian=father, date_start=datetime.date(year=2022, month=2, day=25), date_end=datetime.date(year=2022, month=3, day=3), ) slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=5)) slots = [(x.date.strftime('%d/%m'), str(x.guardian)) for x in slots] assert slots == [ ('22/02', 'John Doe'), ('23/02', 'Jane Doe'), ('24/02', 'Jane Doe'), ('25/02', 'John Doe'), ('26/02', 'John Doe'), ] # check min_date/max_date slots = agenda.get_custody_slots( datetime.date(year=2022, month=2, day=27), datetime.date(year=2022, month=3, day=1) ) slots = [(x.date.strftime('%d/%m'), str(x.guardian)) for x in slots] assert slots == [('27/02', 'John Doe'), ('28/02', 'John Doe')] @pytest.mark.freeze_time('2022-02-22 14:00') # Tuesday def test_shared_custody_agenda_different_periodicity(): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) SharedCustodyRule.objects.create(agenda=agenda, days=[2, 3, 4], guardian=father) SharedCustodyRule.objects.create(agenda=agenda, days=[1, 5, 6, 7], guardian=mother) slots = agenda.get_custody_slots(now().date(), now().date() + datetime.timedelta(days=14)) assert [(x.date.strftime('%A %d/%m'), str(x.guardian)) for x in slots] == [ ('Tuesday 22/02', 'John Doe'), ('Wednesday 23/02', 'John Doe'), ('Thursday 24/02', 'John Doe'), ('Friday 25/02', 'Jane Doe'), ('Saturday 26/02', 'Jane Doe'), ('Sunday 27/02', 'Jane Doe'), ('Monday 28/02', 'Jane Doe'), ('Tuesday 01/03', 'John Doe'), ('Wednesday 02/03', 'John Doe'), ('Thursday 03/03', 'John Doe'), ('Friday 04/03', 'Jane Doe'), ('Saturday 05/03', 'Jane Doe'), ('Sunday 06/03', 'Jane Doe'), ('Monday 07/03', 'Jane Doe'), ] @pytest.mark.parametrize( 'rules,complete', ( ([], False), ([{'days': [0]}], False), ([{'days': [0], 'weeks': 'odd'}], False), ([{'days': list(range(7))}], True), ([{'days': list(range(7)), 'weeks': 'odd'}], False), ([{'days': list(range(7)), 'weeks': 'odd'}, {'days': list(range(7)), 'weeks': 'even'}], True), ([{'days': [0, 1, 2]}, {'days': [3, 4, 5, 6]}], True), ([{'days': [0, 1, 2]}, {'days': [3, 4, 5]}], False), ([{'days': [0, 1, 2, 3]}, {'days': [3, 4, 5, 6]}], False), # overlapping rules, should not exist ( [ {'days': [0, 1, 2]}, {'days': [3, 4, 5]}, {'days': [6], 'weeks': 'odd'}, {'days': [6], 'weeks': 'even'}, ], True, ), ), ) def test_shared_custody_agenda_is_complete(rules, complete): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) for i, rule in enumerate(rules): guardian = father if i % 2 else mother SharedCustodyRule.objects.create(agenda=agenda, guardian=guardian, **rule) assert agenda.is_complete() is complete @pytest.mark.parametrize( 'rules,days,weeks,overlaps', ( ([], [1], '', False), ([{'days': [0]}], [1], '', False), ([{'days': [1]}], [1], '', True), ([{'days': [0]}, {'days': [1]}], [1], '', True), ([{'days': [0], 'weeks': 'odd'}, {'days': [1]}], [1], '', True), ([{'days': [0]}, {'days': [1], 'weeks': 'odd'}], [1], '', True), ([{'days': [0]}, {'days': [1]}], [1], 'odd', True), ([{'days': [0]}, {'days': [1], 'weeks': 'odd'}], [1], 'even', False), ([{'days': [0, 1], 'weeks': 'odd'}], [1], 'odd', True), ([{'days': [0, 1]}], [1], '', True), ([{'days': [0, 1], 'weeks': 'even'}], [1], 'odd', False), ([{'days': [0, 1]}], [0, 3, 4], '', True), ([{'days': [0, 1]}], [2, 3], '', False), ), ) def test_shared_custody_agenda_rule_overlaps(rules, days, weeks, overlaps): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) for i, rule in enumerate(rules): guardian = father if i % 2 else mother SharedCustodyRule.objects.create(agenda=agenda, guardian=guardian, **rule) assert agenda.rule_overlaps(days, weeks) is overlaps def test_shared_custody_agenda_holiday_rule_overlaps(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) summer_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Summer', slug='summer' ) winter_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Winter', slug='winter' ) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=summer_holiday) assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='', instance=rule) is False assert agenda.holiday_rule_overlaps(winter_holiday, years='', periodicity='') is False assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='first-half') is True rule.years = 'odd' rule.save() assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='even', periodicity='') is False rule.periodicity = 'first-half' rule.save() assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='first-half') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='second-half') is False assert ( agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='first-and-third-quarters') is True ) assert ( agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='second-and-fourth-quarters') is True ) assert agenda.holiday_rule_overlaps(summer_holiday, years='even', periodicity='first-half') is False rule.periodicity = 'second-and-fourth-quarters' rule.save() assert agenda.holiday_rule_overlaps(summer_holiday, years='', periodicity='') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='first-half') is True assert agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='second-half') is True assert ( agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='first-and-third-quarters') is False ) assert ( agenda.holiday_rule_overlaps(summer_holiday, years='odd', periodicity='second-and-fourth-quarters') is True ) assert agenda.holiday_rule_overlaps(summer_holiday, years='even', periodicity='first-half') is False @pytest.mark.parametrize( 'periods,date_start,date_end,overlaps', ( ([], '2022-02-03', '2022-02-04', False), ([('2022-02-03', '2022-02-04')], '2022-02-03', '2022-02-04', True), ([('2022-02-03', '2022-02-04')], '2022-02-01', '2022-02-04', True), ([('2022-02-03', '2022-02-04')], '2022-02-03', '2022-02-06', True), ([('2022-02-03', '2022-02-04')], '2022-02-04', '2022-02-06', False), ([('2022-02-03', '2022-02-04')], '2022-02-01', '2022-02-03', False), ([('2022-02-03', '2022-02-04'), ('2022-01-31', '2022-02-01')], '2022-02-01', '2022-02-03', False), ([('2022-02-03', '2022-02-04'), ('2022-01-31', '2022-02-01')], '2022-01-01', '2022-02-10', True), ([('2022-02-03', '2022-02-10')], '2022-02-05', '2022-02-06', True), ), ) def test_shared_custody_agenda_period_overlaps(periods, date_start, date_end, overlaps): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) for i, dates in enumerate(periods): guardian = father if i % 2 else mother SharedCustodyPeriod.objects.create( agenda=agenda, guardian=guardian, date_start=dates[0], date_end=dates[1] ) assert agenda.period_overlaps(date_start, date_end) is overlaps def test_shared_custody_agenda_period_holiday_rule_no_overlaps(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) summer_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Summer', slug='summer' ) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=summer_holiday) SharedCustodyPeriod.objects.create( holiday_rule=rule, agenda=agenda, guardian=father, date_start='2022-02-03', date_end='2022-02-05' ) assert agenda.period_overlaps('2022-02-03', '2022-02-05') is False def test_shared_custody_agenda_rule_label(): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) rule = SharedCustodyRule.objects.create(agenda=agenda, guardian=father, days=list(range(1, 8))) assert rule.label == 'daily' rule.days = [2, 3, 4, 5] rule.save() assert rule.label == 'from Tuesday to Friday' rule.days = [5, 6, 7] rule.save() assert rule.label == 'from Friday to Sunday' rule.days = [2, 5, 7] rule.save() assert rule.label == 'on Tuesdays, Fridays, Sundays' rule.days = [1] rule.weeks = 'even' rule.save() assert rule.label == 'on Mondays, on even weeks' rule.weeks = 'odd' rule.save() assert rule.label == 'on Mondays, on odd weeks' def test_shared_custody_agenda_holiday_rule_label(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) summer_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Summer Holidays', slug='summer' ) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=summer_holiday) assert rule.label == 'Summer Holidays' rule.years = 'even' rule.save() assert rule.label == 'Summer Holidays, on even years' rule.years = 'odd' rule.save() assert rule.label == 'Summer Holidays, on odd years' rule.periodicity = 'first-half' rule.save() assert rule.label == 'Summer Holidays, the first half, on odd years' rule.years = '' rule.periodicity = 'second-half' rule.save() assert rule.label == 'Summer Holidays, the second half' rule.periodicity = 'first-and-third-quarters' rule.save() assert rule.label == 'Summer Holidays, the first and third quarters' rule.periodicity = 'second-and-fourth-quarters' rule.save() assert rule.label == 'Summer Holidays, the second and fourth quarters' def test_shared_custody_agenda_period_label(freezer): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) period = SharedCustodyPeriod.objects.create( agenda=agenda, guardian=father, date_start=datetime.date(2021, 7, 10), date_end=datetime.date(2021, 7, 11), ) assert str(period) == 'John Doe, 07/10/2021' period.date_end = datetime.date(2021, 7, 13) period.save() assert str(period) == 'John Doe, 07/10/2021 → 07/13/2021' def test_shared_custody_agenda_holiday_rule_create_periods(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) summer_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Summer', slug='summer' ) TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2021, month=7, day=6, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2021, month=9, day=2, hour=0, minute=0)), group=summer_holiday, ) TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2022, month=7, day=7, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2022, month=9, day=1, hour=0, minute=0)), group=summer_holiday, ) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=summer_holiday) rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.holiday_rule == rule assert period1.guardian == father assert period1.agenda == agenda assert period1.date_start == datetime.date(year=2021, month=7, day=6) assert period1.date_end == datetime.date(year=2021, month=9, day=2) assert period2.holiday_rule == rule assert period2.guardian == father assert period2.agenda == agenda assert period2.date_start == datetime.date(year=2022, month=7, day=7) assert period2.date_end == datetime.date(year=2022, month=9, day=1) rule.years = 'odd' rule.update_or_create_periods() period = SharedCustodyPeriod.objects.get() assert period.date_start == datetime.date(year=2021, month=7, day=6) assert period.date_end == datetime.date(year=2021, month=9, day=2) rule.years = 'even' rule.update_or_create_periods() period = SharedCustodyPeriod.objects.get() assert period.date_start == datetime.date(year=2022, month=7, day=7) assert period.date_end == datetime.date(year=2022, month=9, day=1) rule.years = '' rule.periodicity = 'first-half' rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=7, day=6) assert period1.date_end == datetime.date(year=2021, month=8, day=1) assert period1.date_end.weekday() == 6 assert period2.date_start == datetime.date(year=2022, month=7, day=7) assert period2.date_end == datetime.date(year=2022, month=7, day=31) assert period2.date_end.weekday() == 6 rule.periodicity = 'second-half' rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=8, day=1) assert period1.date_start.weekday() == 6 assert period1.date_end == datetime.date(year=2021, month=9, day=2) assert period2.date_start == datetime.date(year=2022, month=7, day=31) assert period2.date_start.weekday() == 6 assert period2.date_end == datetime.date(year=2022, month=9, day=1) rule.periodicity = 'first-and-third-quarters' rule.update_or_create_periods() period1, period2, period3, period4 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=7, day=6) assert period1.date_end == datetime.date(year=2021, month=7, day=25) assert period1.date_end.weekday() == 6 assert period2.date_start == datetime.date(year=2021, month=8, day=8) assert period2.date_end == datetime.date(year=2021, month=8, day=22) assert period2.date_end.weekday() == 6 assert period3.date_start == datetime.date(year=2022, month=7, day=7) assert period3.date_end == datetime.date(year=2022, month=7, day=24) assert period3.date_end.weekday() == 6 assert period4.date_start == datetime.date(year=2022, month=8, day=7) assert period4.date_end == datetime.date(year=2022, month=8, day=21) assert period4.date_end.weekday() == 6 rule.periodicity = 'second-and-fourth-quarters' rule.update_or_create_periods() period1, period2, period3, period4 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=7, day=25) assert period1.date_start.weekday() == 6 assert period1.date_end == datetime.date(year=2021, month=8, day=8) assert period2.date_start == datetime.date(year=2021, month=8, day=22) assert period2.date_start.weekday() == 6 assert period2.date_end == datetime.date(year=2021, month=9, day=2) assert period3.date_start == datetime.date(year=2022, month=7, day=24) assert period3.date_start.weekday() == 6 assert period3.date_end == datetime.date(year=2022, month=8, day=7) assert period4.date_start == datetime.date(year=2022, month=8, day=21) assert period4.date_start.weekday() == 6 assert period4.date_end == datetime.date(year=2022, month=9, day=1) def test_shared_custody_agenda_holiday_rule_create_periods_christmas_holidays(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) christmas_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Christmas', slug='christmas' ) TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2021, month=12, day=18, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2022, month=1, day=3, hour=0, minute=0)), group=christmas_holiday, ) TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2022, month=12, day=17, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2023, month=1, day=3, hour=0, minute=0)), group=christmas_holiday, ) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=christmas_holiday) rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=12, day=18) assert period1.date_end == datetime.date(year=2022, month=1, day=3) assert period2.date_start == datetime.date(year=2022, month=12, day=17) assert period2.date_end == datetime.date(year=2023, month=1, day=3) rule.periodicity = 'first-half' rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=12, day=18) assert period1.date_end == datetime.date(year=2021, month=12, day=26) assert period1.date_end.weekday() == 6 assert period2.date_start == datetime.date(year=2022, month=12, day=17) assert period2.date_end == datetime.date(year=2022, month=12, day=25) assert period2.date_end.weekday() == 6 rule.periodicity = 'second-half' rule.update_or_create_periods() period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=12, day=26) assert period1.date_start.weekday() == 6 assert period1.date_end == datetime.date(year=2022, month=1, day=3) assert period2.date_start == datetime.date(year=2022, month=12, day=25) assert period2.date_start.weekday() == 6 assert period2.date_end == datetime.date(year=2023, month=1, day=3) rule.periodicity = 'first-and-third-quarters' rule.update_or_create_periods() assert not SharedCustodyPeriod.objects.exists() rule.periodicity = 'second-and-fourth-quarters' rule.update_or_create_periods() assert not SharedCustodyPeriod.objects.exists() def test_shared_custody_agenda_holiday_rules_application(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) christmas_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Christmas', slug='christmas' ) TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2021, month=12, day=18, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2022, month=1, day=3, hour=0, minute=0)), group=christmas_holiday, ) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='even', guardian=father) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='odd', guardian=mother) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=christmas_holiday) rule.update_or_create_periods() date_start = datetime.date(year=2021, month=12, day=13) # Monday, even week slots = agenda.get_custody_slots(date_start, date_start + datetime.timedelta(days=30)) guardians = [str(x.guardian) for x in slots] assert all(name == 'John Doe' for name in guardians[:21]) assert all(name == 'Jane Doe' for name in guardians[21:28]) assert all(name == 'John Doe' for name in guardians[28:]) # check exceptional custody periods take precedence over holiday rules SharedCustodyPeriod.objects.create( agenda=agenda, guardian=mother, date_start=datetime.date(year=2021, month=12, day=27), date_end=datetime.date(year=2021, month=12, day=29), ) slots = agenda.get_custody_slots(date_start, date_start + datetime.timedelta(days=30)) slots = [(x.date.strftime('%d/%m'), str(x.guardian)) for x in slots] assert slots[13:17] == [ ('26/12', 'John Doe'), ('27/12', 'Jane Doe'), ('28/12', 'Jane Doe'), ('29/12', 'John Doe'), ] def test_shared_custody_agenda_update_holiday_rules_command(): calendar = UnavailabilityCalendar.objects.create(label='Calendar') father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=now() ) christmas_holiday = TimePeriodExceptionGroup.objects.create( unavailability_calendar=calendar, label='Christmas', slug='christmas' ) exception = TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2021, month=12, day=18, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2022, month=1, day=3, hour=0, minute=0)), group=christmas_holiday, ) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='even', guardian=father) SharedCustodyRule.objects.create(agenda=agenda, days=list(range(1, 8)), weeks='odd', guardian=mother) rule = SharedCustodyHolidayRule.objects.create(agenda=agenda, guardian=father, holiday=christmas_holiday) rule.update_or_create_periods() period = SharedCustodyPeriod.objects.get() assert period.date_start == datetime.date(year=2021, month=12, day=18) assert period.date_end == datetime.date(year=2022, month=1, day=3) exception.start_datetime += datetime.timedelta(days=1) exception.save() TimePeriodException.objects.create( unavailability_calendar=calendar, start_datetime=make_aware(datetime.datetime(year=2022, month=12, day=18, hour=0, minute=0)), end_datetime=make_aware(datetime.datetime(year=2023, month=1, day=3, hour=0, minute=0)), group=christmas_holiday, ) call_command('update_shared_custody_holiday_rules') period1, period2 = SharedCustodyPeriod.objects.all() assert period1.date_start == datetime.date(year=2021, month=12, day=19) assert period1.date_end == datetime.date(year=2022, month=1, day=3) assert period2.date_start == datetime.date(year=2022, month=12, day=18) assert period2.date_end == datetime.date(year=2023, month=1, day=3) def test_shared_custody_agenda_unique_child_no_date_end(): father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') child = Person.objects.create(user_external_id='child_id', first_name='James', last_name='Doe') SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=datetime.date(2020, 1, 1), date_end=datetime.date(2021, 1, 1), ) agenda = SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=datetime.date(2022, 1, 1) ) with pytest.raises(IntegrityError): with transaction.atomic(): SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=datetime.date(2023, 1, 1), ) agenda.date_end = datetime.date(2022, 6, 1) agenda.save() SharedCustodyAgenda.objects.create( first_guardian=father, second_guardian=mother, child=child, date_start=datetime.date(2023, 1, 1) ) def test_meetings_agenda_get_free_time(db, freezer): freezer.move_to(paris('2023-04-02 19:10')) agenda = build_agendas( ''' meetings Agenda 30 desk "Desk 1" monday-friday 08:00-12:00 14:00-17:00 unavailability-calendar Congés exception Avril paris('2023-04-05 00:00:00 24h') desk "Desk 2" monday-friday 09:00-12:00 unavailability-calendar Congés desk "Desk 3" monday-friday 15:00-17:00 unavailability-calendar Congés ''' ) full_free_time = IntervalSet( [ paris('2023-04-03 08:00 4h'), paris('2023-04-03 14:00 3h'), paris('2023-04-04 08:00 4h'), paris('2023-04-04 14:00 3h'), paris('2023-04-06 08:00 4h'), paris('2023-04-06 14:00 3h'), paris('2023-04-07 08:00 4h'), paris('2023-04-07 14:00 3h'), paris('2023-04-08 08:00 2h'), ] ) def closed(): return full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) add_day_timeperiod(agenda._desk_1, paris('2023-04-08 08:00 2h')) assert agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == full_free_time assert full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == [] assert closed() == [] assert ( agenda.get_free_time(start_datetime=paris('2023-04-03 13:00'), end_datetime=paris('2023-04-09 19:10')) == list(full_free_time)[1:] ) # desk1 is closed on monday 3th april form 8' to 12' but # desk2 is still open from 9' to 12', so free time is only # diminished of the 8' to 9' hour. add_exception(agenda._desk_1, paris('2023-04-03 08:00 4h')) assert closed() == [paris('2023-04-03 08:00 1h')] # now close desk2 from 11' to 12' on monday 3th add_exception(agenda._desk_2, paris('2023-04-03 11:00 1h')) assert closed() == [paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h')] # add a meeting on tuesday 4th april on desk1 at 14'30 add_meeting(agenda._desk_1, paris('2023-04-04 14:30')) # an event on 5th at 15' but slot should still be open on desk3 add_meeting(agenda._desk_1, paris('2023-04-05 15:00')) # two events on 6th at 15', slot is closed add_meeting(agenda._desk_1, paris('2023-04-06 15:00')) add_meeting(agenda._desk_3, paris('2023-04-06 15:00')) assert closed() == [ paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h'), paris('2023-04-04 14:30 30m'), paris('2023-04-06 15:00 30m'), ] def test_virtual_agenda_get_free_time(db, freezer): freezer.move_to(paris('2023-04-02 19:10')) # context: # * a virtual agenda, containing: # * three real agendas, with timperiods of monday to friday and a 30 minutes meeting type # - agenda 1. 8-12h/14-17h # - agenda 2. 9-12h # - agenda 3. 15-17h # * looking at week from monday 3th april 2023 to friday 7th april 2023 agenda = build_agendas( ''' virtual Agenda 30 meetings Agenda-1 desk Desk-1 monday-friday 08:00-12:00 14:00-17:00 unavailability-calendar Congés exception Avril paris('2023-04-05 00:00:00 24h') meetings Agenda-2 desk Desk-1 monday-friday 09:00-12:00 unavailability-calendar Congés meetings Agenda-3 desk Desk-1 monday-friday 15:00-17:00 unavailability-calendar Congés ''' ) full_free_time = IntervalSet( [ paris('2023-04-03 08:00 4h'), paris('2023-04-03 14:00 3h'), paris('2023-04-04 08:00 4h'), paris('2023-04-04 14:00 3h'), paris('2023-04-06 08:00 4h'), paris('2023-04-06 14:00 3h'), paris('2023-04-07 08:00 4h'), paris('2023-04-07 14:00 3h'), paris('2023-04-08 08:00 2h'), ] ) def closed(): return full_free_time - agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) add_day_timeperiod(agenda._agenda_1, paris('2023-04-08 08:00 2h')) assert agenda.get_free_time(end_datetime=paris('2023-04-09 19:10')) == full_free_time assert ( agenda.get_free_time(start_datetime=paris('2023-04-03 13:00'), end_datetime=paris('2023-04-09 19:10')) == list(full_free_time)[1:] ) # agenda1.desk is closed on monday 3th april form 8' to 12' but # agenda2.desk is still open from 9' to 12', so free time is only # diminished of the 8' to 9' hour. add_exception(agenda._agenda_1, paris('2023-04-03 08:00 4h')) assert closed() == [paris('2023-04-03 08:00 1h')] # now close agenda2.desk from 11' to 12' on monday 3th add_exception(agenda._agenda_2, paris('2023-04-03 11:00 1h')) assert closed() == [paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h')] # add a meeting on tuesday 4th april on agenda1.desk at 14'30 add_meeting(agenda._agenda_1, paris('2023-04-04 14:30')) # an event on 5th at 15' but slot should still be open on agenda3 add_meeting(agenda._agenda_1, paris('2023-04-05 15:00')) # two events on 6th at 15', slot is closed add_meeting(agenda._agenda_1, paris('2023-04-06 15:00')) add_meeting(agenda._agenda_3, paris('2023-04-06 15:00')) assert closed() == [ paris('2023-04-03 08:00 1h'), paris('2023-04-03 11:00 1h'), paris('2023-04-04 14:30 30m'), paris('2023-04-06 15:00 30m'), ] def test_agenda_event_overlaps(): agenda = Agenda.objects.create(label='Agenda', kind='events') start_datetime = make_aware(datetime.datetime(2023, 5, 1, 14, 00)) event_kwargs = { 'start_datetime': start_datetime, 'recurrence_days': None, 'recurrence_end_date': None, } assert agenda.event_overlaps(**event_kwargs) is False event = Event.objects.create(start_datetime=start_datetime, agenda=agenda, places=1) assert agenda.event_overlaps(**event_kwargs) is True assert agenda.event_overlaps(instance=event, **event_kwargs) is False event_kwargs['start_datetime'] = start_datetime + datetime.timedelta(days=1) assert agenda.event_overlaps(**event_kwargs) is False event_kwargs['start_datetime'] = start_datetime.replace(month=2) assert agenda.event_overlaps(**event_kwargs) is False # recurring event event_kwargs = { 'start_datetime': start_datetime, 'recurrence_days': [1, 2], 'recurrence_end_date': None, } assert agenda.event_overlaps(**event_kwargs) is True # same weekday, starts after event_kwargs['start_datetime'] = start_datetime + datetime.timedelta(days=7) assert agenda.event_overlaps(**event_kwargs) is False # same weekday, starts before event_kwargs['start_datetime'] = start_datetime - datetime.timedelta(days=7) assert agenda.event_overlaps(**event_kwargs) is True # same weekday, starts before event_kwargs['start_datetime'] = start_datetime - datetime.timedelta(days=6) assert agenda.event_overlaps(**event_kwargs) is True # different weekday, starts before event_kwargs['recurrence_days'] = [3] assert agenda.event_overlaps(**event_kwargs) is False # same weekday, starts before, ends before event_kwargs = { 'start_datetime': start_datetime - datetime.timedelta(days=30), 'recurrence_days': [1, 2], 'recurrence_end_date': start_datetime - datetime.timedelta(days=15), } assert agenda.event_overlaps(**event_kwargs) is False # same weekday, starts before, ends after event_kwargs['recurrence_end_date'] = start_datetime + datetime.timedelta(days=15) assert agenda.event_overlaps(**event_kwargs) is True def test_agenda_event_overlaps_recurring(): agenda = Agenda.objects.create(label='Agenda', kind='events') event_kwargs = { 'start_datetime': make_aware(datetime.datetime(2023, 5, 1, 14, 00)), 'recurrence_days': [1, 2], 'recurrence_end_date': datetime.date(2023, 6, 1), } assert agenda.event_overlaps(**event_kwargs) is False event = Event.objects.create(agenda=agenda, places=1, **event_kwargs) event.create_all_recurrences() assert agenda.event_overlaps(**event_kwargs) is True assert agenda.event_overlaps(instance=event, **event_kwargs) is False # starts before, ends during existing event event_kwargs = { 'start_datetime': make_aware(datetime.datetime(2023, 4, 1, 14, 00)), 'recurrence_days': [1], 'recurrence_end_date': datetime.date(2023, 5, 15), } assert agenda.event_overlaps(**event_kwargs) is True assert agenda.event_overlaps(instance=event, **event_kwargs) is False # starts during, ends after existing event event_kwargs = { 'start_datetime': make_aware(datetime.datetime(2023, 5, 15, 14, 00)), 'recurrence_days': [2], 'recurrence_end_date': datetime.date(2023, 6, 15), } assert agenda.event_overlaps(**event_kwargs) is True # not the same day event_kwargs['recurrence_days'] = [3, 4] assert agenda.event_overlaps(**event_kwargs) is False # starts after event_kwargs = { 'start_datetime': make_aware(datetime.datetime(2023, 6, 15, 14, 00)), 'recurrence_days': [2], 'recurrence_end_date': datetime.date(2023, 7, 15), } assert agenda.event_overlaps(**event_kwargs) is False # remove recurrence_end_date from event event.recurrence_end_date = None event.save() assert agenda.event_overlaps(**event_kwargs) is True # starts before, ends before event_kwargs = { 'start_datetime': make_aware(datetime.datetime(2023, 4, 1, 14, 00)), 'recurrence_days': [2], 'recurrence_end_date': datetime.date(2023, 4, 20), } assert agenda.event_overlaps(**event_kwargs) is False # starts before, not end event_kwargs['recurrence_end_date'] = None assert agenda.event_overlaps(**event_kwargs) is True # normal event, before event_kwargs['recurrence_days'] = None assert agenda.event_overlaps(**event_kwargs) is False # normal event, after on a recurrence day event_kwargs['start_datetime'] = make_aware(datetime.datetime(2023, 5, 9, 14, 00)) assert agenda.event_overlaps(**event_kwargs) is True # normal event, after not on a recurrence day event_kwargs['start_datetime'] = make_aware(datetime.datetime(2023, 5, 10, 14, 00)) assert agenda.event_overlaps(**event_kwargs) is False @pytest.mark.parametrize( 'start_time, user_check_start_time, tolerance, unit, expected', [ # no check (None, None, 0, 'hour', None), (None, None, 0, 'half_hour', None), (None, None, 0, 'quarter', None), (None, None, 0, 'minutes', None), # hour unit - no booking (None, datetime.time(7, 50), 10, 'hour', datetime.time(8, 0)), (None, datetime.time(7, 49), 10, 'hour', datetime.time(7, 0)), (None, datetime.time(7, 50), 0, 'hour', datetime.time(7, 0)), # hour unit - with booking (datetime.time(8, 0), datetime.time(7, 50), 10, 'hour', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 49), 10, 'hour', datetime.time(7, 0)), (datetime.time(8, 0), datetime.time(8, 30), 10, 'hour', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 50), 0, 'hour', datetime.time(7, 0)), # half_hour unit - no booking (None, datetime.time(7, 50), 10, 'half_hour', datetime.time(8, 0)), (None, datetime.time(7, 49), 10, 'half_hour', datetime.time(7, 30)), (None, datetime.time(7, 50), 0, 'half_hour', datetime.time(7, 30)), # half_hour unit - with booking (datetime.time(8, 0), datetime.time(7, 50), 10, 'half_hour', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 49), 10, 'half_hour', datetime.time(7, 30)), (datetime.time(8, 0), datetime.time(8, 30), 10, 'half_hour', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 50), 0, 'half_hour', datetime.time(7, 30)), # quarter unit - no booking (None, datetime.time(7, 50), 10, 'quarter', datetime.time(8, 0)), (None, datetime.time(7, 49), 10, 'quarter', datetime.time(7, 45)), (None, datetime.time(7, 50), 0, 'quarter', datetime.time(7, 45)), # quarter unit - with booking (datetime.time(8, 0), datetime.time(7, 50), 10, 'quarter', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 49), 10, 'quarter', datetime.time(7, 45)), (datetime.time(8, 0), datetime.time(8, 30), 10, 'quarter', datetime.time(8, 0)), (datetime.time(8, 0), datetime.time(7, 50), 0, 'quarter', datetime.time(7, 45)), # minute unit - no booking (None, datetime.time(7, 50), 0, 'minute', datetime.time(7, 50)), # minute unit - with booking (datetime.time(8, 0), datetime.time(7, 50), 0, 'minute', datetime.time(7, 50)), (datetime.time(8, 0), datetime.time(8, 5), 0, 'minute', datetime.time(8, 0)), ], ) def test_booking_get_computed_start_time(start_time, user_check_start_time, tolerance, unit, expected): agenda = Agenda.objects.create( label='Agenda', kind='events', invoicing_unit=unit, invoicing_tolerance=tolerance ) event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create(event=event, start_time=start_time) booking.mark_user_presence(start_time=user_check_start_time) assert booking.user_check.get_computed_start_time() == expected @pytest.mark.parametrize( 'end_time, user_check_end_time, tolerance, unit, expected', [ # no check (None, None, 0, 'hour', None), (None, None, 0, 'half_hour', None), (None, None, 0, 'quarter', None), (None, None, 0, 'minutes', None), # hour unit - no booking (None, datetime.time(17, 10), 10, 'hour', datetime.time(17, 0)), (None, datetime.time(17, 11), 10, 'hour', datetime.time(18, 0)), (None, datetime.time(17, 10), 0, 'hour', datetime.time(18, 0)), # hour unit - with booking (datetime.time(17, 0), datetime.time(17, 10), 10, 'hour', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 11), 10, 'hour', datetime.time(18, 0)), (datetime.time(17, 0), datetime.time(16, 30), 10, 'hour', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 10), 0, 'hour', datetime.time(18, 0)), # half_hour unit - no booking (None, datetime.time(17, 10), 10, 'half_hour', datetime.time(17, 0)), (None, datetime.time(17, 11), 10, 'half_hour', datetime.time(17, 30)), (None, datetime.time(17, 10), 0, 'half_hour', datetime.time(17, 30)), # half_hour unit - with booking (datetime.time(17, 0), datetime.time(17, 10), 10, 'half_hour', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 11), 10, 'half_hour', datetime.time(17, 30)), (datetime.time(17, 0), datetime.time(16, 30), 10, 'half_hour', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 10), 0, 'half_hour', datetime.time(17, 30)), # quarter unit - no booking (None, datetime.time(17, 10), 10, 'quarter', datetime.time(17, 0)), (None, datetime.time(17, 11), 10, 'quarter', datetime.time(17, 15)), (None, datetime.time(17, 10), 0, 'quarter', datetime.time(17, 15)), # quarter unit - with booking (datetime.time(17, 0), datetime.time(17, 10), 10, 'quarter', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 11), 10, 'quarter', datetime.time(17, 15)), (datetime.time(17, 0), datetime.time(16, 30), 10, 'quarter', datetime.time(17, 0)), (datetime.time(17, 0), datetime.time(17, 10), 0, 'quarter', datetime.time(17, 15)), # minute unit - no booking (None, datetime.time(17, 10), 0, 'minute', datetime.time(17, 10)), # minute unit - with booking (datetime.time(17, 0), datetime.time(17, 10), 0, 'minute', datetime.time(17, 10)), (datetime.time(17, 0), datetime.time(16, 50), 0, 'minute', datetime.time(17, 0)), ], ) def test_booking_get_computed_end_time(end_time, user_check_end_time, tolerance, unit, expected): agenda = Agenda.objects.create( label='Agenda', kind='events', invoicing_unit=unit, invoicing_tolerance=tolerance ) event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create(event=event, end_time=end_time) booking.mark_user_presence(end_time=user_check_end_time) assert booking.user_check.get_computed_end_time() == expected def test_booking_refresh_computed_times_only_one_user_check(): agenda = Agenda.objects.create( label='Agenda', kind='events', invoicing_unit='half_hour', invoicing_tolerance=10 ) event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create( event=event, start_time=datetime.time(8, 0), end_time=datetime.time(17, 0) ) # only one user_check, presence or absence, same behavior booking_check = BookingCheck.objects.create( booking=booking, presence=True, start_time=datetime.time(7, 45), end_time=datetime.time(17, 15) ) booking.refresh_computed_times(commit=True) booking_check.refresh_from_db() assert booking_check.computed_start_time == datetime.time(7, 30) assert booking_check.computed_end_time == datetime.time(17, 30) booking_check.presence = False booking.refresh_computed_times(commit=True) booking_check.refresh_from_db() assert booking_check.computed_start_time == datetime.time(7, 30) assert booking_check.computed_end_time == datetime.time(17, 30) @pytest.mark.parametrize( 'presence1, start_time1, end_time1, presence2, start_time2, end_time2, expected_start1, expected_end1, expected_start2, expected_end2', [ # first presence, then absence ( True, datetime.time(7, 45), datetime.time(12, 15), False, datetime.time(13, 0), # no overlapping datetime.time(15, 0), datetime.time(7, 30), datetime.time(12, 30), datetime.time(12, 30), datetime.time(17, 0), ), ( True, datetime.time(7, 45), datetime.time(12, 15), False, datetime.time(12, 15), # computed start should be 12H00 datetime.time(15, 0), datetime.time(7, 30), datetime.time(12, 30), datetime.time(12, 30), # but it is adjusted to 12H30 datetime.time(17, 0), ), # first absence, then presence ( False, datetime.time(7, 45), datetime.time(12, 0), # no overlapping True, datetime.time(13, 15), datetime.time(15, 0), datetime.time(7, 30), datetime.time(13, 0), datetime.time(13, 0), datetime.time(17, 0), ), ( False, datetime.time(7, 45), datetime.time(12, 45), # computed end should be 13H00 True, datetime.time(12, 45), datetime.time(15, 0), datetime.time(7, 30), datetime.time(12, 30), # but it is adjusted to 12H30 datetime.time(12, 30), datetime.time(17, 0), ), ], ) def test_booking_refresh_computed_times_two_user_check( presence1, start_time1, end_time1, presence2, start_time2, end_time2, expected_start1, expected_end1, expected_start2, expected_end2, ): agenda = Agenda.objects.create( label='Agenda', kind='events', invoicing_unit='half_hour', invoicing_tolerance=10 ) event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create( event=event, start_time=datetime.time(8, 0), end_time=datetime.time(17, 0) ) user_check1 = BookingCheck.objects.create( booking=booking, presence=presence1, start_time=start_time1, end_time=end_time1 ) user_check2 = BookingCheck.objects.create( booking=booking, presence=presence2, start_time=start_time2, end_time=end_time2 ) booking.refresh_computed_times(commit=True) user_check1.refresh_from_db() user_check2.refresh_from_db() assert user_check1.computed_start_time == expected_start1 assert user_check1.computed_end_time == expected_end1 assert user_check2.computed_start_time == expected_start2 assert user_check2.computed_end_time == expected_end2 def test_agenda_refresh_booking_computed_times(): agenda = Agenda.objects.create( label='Agenda', kind='events', partial_bookings=True, ) agenda2 = Agenda.objects.create(label='other') assert agenda.invoicing_unit == 'hour' assert agenda.invoicing_tolerance == 0 event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create( event=event, start_time=datetime.time(8, 0), end_time=datetime.time(17, 0), ) booking.mark_user_presence(start_time=datetime.time(7, 55), end_time=datetime.time(17, 15)) def reset_booking(): booking.user_check.computed_start_time = None booking.user_check.computed_end_time = None booking.user_check.save() def test_booking(success): with CaptureQueriesContext(connection) as ctx: agenda.refresh_booking_computed_times() assert len(ctx.captured_queries) in [1, 3] booking.refresh_from_db() if success is True: assert booking.user_check.computed_start_time == datetime.time(7, 0) assert booking.user_check.computed_end_time == datetime.time(18, 0) reset_booking() else: assert booking.user_check.computed_start_time is booking.user_check.computed_end_time is None test_booking(True) # wrong agenda kind agenda.partial_bookings = False agenda.save() test_booking(False) agenda.partial_bookings = True agenda.kind = 'meetings' agenda.save() test_booking(False) agenda.kind = 'virtual' agenda.save() test_booking(False) # reset kind agenda.kind = 'events' agenda.save() test_booking(True) # wrong agenda event.agenda = agenda2 event.save() test_booking(False) # event check locked event.agenda = agenda event.check_locked = True event.save() test_booking(False) # event invoiced event.check_locked = False event.invoiced = True event.save() test_booking(False) # event cancelled event.invoiced = False event.cancelled = True event.save() test_booking(False) # booking cancelled event.cancelled = False event.save() booking.cancellation_datetime = now() booking.save() test_booking(False) # ok booking.cancellation_datetime = None booking.save() test_booking(True) def test_event_refresh_booking_computed_times(): agenda = Agenda.objects.create( label='Agenda', kind='events', partial_bookings=True, ) assert agenda.invoicing_unit == 'hour' assert agenda.invoicing_tolerance == 0 event = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) event2 = Event.objects.create( agenda=agenda, start_datetime=make_aware(datetime.datetime(2023, 5, 1, 14, 00)), places=10, ) booking = Booking.objects.create( event=event, start_time=datetime.time(8, 0), end_time=datetime.time(17, 0), ) booking.mark_user_presence(start_time=datetime.time(7, 55), end_time=datetime.time(17, 15)) def reset_booking(): booking.user_check.computed_start_time = None booking.user_check.computed_end_time = None booking.user_check.save() def test_booking(success): with CaptureQueriesContext(connection) as ctx: event.refresh_booking_computed_times() assert len(ctx.captured_queries) in [1, 3] booking.refresh_from_db() if success is True: assert booking.user_check.computed_start_time == datetime.time(7, 0) assert booking.user_check.computed_end_time == datetime.time(18, 0) reset_booking() else: assert booking.user_check.computed_start_time is booking.user_check.computed_end_time is None test_booking(True) # wrong agenda kind agenda.partial_bookings = False agenda.save() test_booking(False) agenda.partial_bookings = True agenda.kind = 'meetings' agenda.save() test_booking(False) agenda.kind = 'virtual' agenda.save() test_booking(False) # reset kind agenda.kind = 'events' agenda.save() test_booking(True) # wrong event booking.event = event2 booking.save() test_booking(False) # event check locked booking.event = event booking.save() event.check_locked = True event.save() test_booking(False) # event invoiced event.check_locked = False event.invoiced = True event.save() test_booking(False) # event cancelled event.invoiced = False event.cancelled = True event.save() test_booking(False) # booking cancelled event.cancelled = False event.save() booking.cancellation_datetime = now() booking.save() test_booking(False) # ok 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 def test_agenda_booking_max_two_user_checks(): 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) BookingCheck.objects.create(booking=booking, presence=True) # cannot create second check with presence=True with pytest.raises(IntegrityError): with transaction.atomic(): BookingCheck.objects.create(booking=booking, presence=True) # allow to create second check with presence=False BookingCheck.objects.create(booking=booking, presence=False) # cannot create second check with presence=False with pytest.raises(IntegrityError): with transaction.atomic(): BookingCheck.objects.create(booking=booking, presence=False)