From fb30c875ce92d30c6d1e6c21b3e5a1fa7ba44486 Mon Sep 17 00:00:00 2001 From: Carlos Vega <carlos.vega@uni.lu> Date: Wed, 7 Nov 2018 17:20:47 +0100 Subject: [PATCH] Fix office availability to avoid non-overlapping events and ensure overlaps stay within bounds --- smash/web/models/worker.py | 24 +++++-- smash/web/officeAvailability.py | 39 +++++++++++- smash/web/tests/test_office_availability.py | 70 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/smash/web/models/worker.py b/smash/web/models/worker.py index 85037ed1..0c38dfef 100644 --- a/smash/web/models/worker.py +++ b/smash/web/models/worker.py @@ -187,12 +187,28 @@ class Worker(models.Model): office_availability = OfficeAvailability('{} {}'.format(self.first_name, self.last_name), start=start_date, end=end_date) - #Appointments - subject_appointments = AppointmentTypeLink.objects.filter(worker=self.id, date_when__gte=start_date, date_when__lte=end_date) - general_appointments = Appointment.objects.filter(worker_assigned=self.id, datetime_when__gte=start_date, datetime_when__lte=end_date) + #Subject Appointments + old_events = Q(date_when__gt=start_date) & Q(date_when__gt=end_date) + future_events = Q(date_when__lt=start_date) & Q(date_when__lt=end_date) + non_overlap_events = old_events | future_events + overlap_events = ~non_overlap_events + query = Q(worker=self.id) & overlap_events + subject_appointments = AppointmentTypeLink.objects.filter(query) + + #General Appointments + old_events = Q(datetime_when__gt=start_date) & Q(datetime_when__gt=end_date) + future_events = Q(datetime_when__lt=start_date) & Q(datetime_when__lt=end_date) + non_overlap_events = old_events | future_events + overlap_events = ~non_overlap_events + query = Q(worker_assigned=self.id) & overlap_events + general_appointments = Appointment.objects.filter(query) #Holidays and extra availabilities. - holidays_and_extra_availabilities = self.holiday_set.filter(datetime_start__gte=start_date, datetime_end__lt=end_date).order_by('-datetime_start') + old_events = Q(datetime_start__gt=start_date) & Q(datetime_start__gt=end_date) + future_events = Q(datetime_end__lt=start_date) & Q(datetime_end__lt=end_date) + non_overlap_events = old_events | future_events + overlap_events = ~non_overlap_events + holidays_and_extra_availabilities = self.holiday_set.filter(overlap_events).order_by('-datetime_start') #Availability weekdays = get_weekdays_in_period(start_date, end_date) diff --git a/smash/web/officeAvailability.py b/smash/web/officeAvailability.py index 1ee73256..de883fca 100644 --- a/smash/web/officeAvailability.py +++ b/smash/web/officeAvailability.py @@ -44,7 +44,7 @@ class OfficeAvailability(object): self.office_end = office_end self.minimum_slot = minimum_slot self.range = pd.date_range(start=self.start, end=self.end, freq=self.minimum_slot) - logger.debug('Min index: {}. Max index: {}'.format(self.start, self.end)) + logger.debug(u'Name: {}. Min index: {}. Max index: {}'.format(self.name, self.start, self.end)) self.availability = pd.Series(index=self.range, data=0) # initialize range at 0 def _get_duration(self): @@ -73,6 +73,27 @@ class OfficeAvailability(object): range = range.to_series().between_time(self.office_start, self.office_end).index self.availability[range] = 0 + def _ensure_dates_are_in_bounds(self, given_start, given_end): + ''' + given_start and given_end should not be a string but if so, they must comply with pd.Timestamp requirements + ''' + # sort dates to ensure start < end + start, end = sorted([pd.Timestamp(given_start), pd.Timestamp(given_end)]) + + if start < self.availability.index.min(): + start = self.availability.index.min() + + # check if end is in bounds + if end > self.availability.index.max(): + end = self.availability.index.max() + + # this could only happen if both start and end dates are higher or lower than index.max/min since start and end dates are sorted + # this means that the two dates are out of bounds and then its time range doesn't overlap with the self.availability + if start > self.availability.index.max() or end < self.availability.index.min(): + raise ValueError + + return start, end + def consider_this(self, appointment_availability_or_holiday, only_working_hours=False): ''' :appointment_availability_or_holiday can be an object from the following classes: Availability, Holiday, Appointment, AppointmentTypeLink. @@ -106,19 +127,35 @@ class OfficeAvailability(object): elif isinstance(appointment_availability_or_holiday, Holiday): start = appointment_availability_or_holiday.datetime_start end = appointment_availability_or_holiday.datetime_end + # ensure the start and end dates are in the same range to avoid memory issues (for example, someone asking from 1960 to 2120 creating a huge pd.Range) logger.debug('Considering {} from {} to {}'.format('Extra Availability' if appointment_availability_or_holiday.kind == AVAILABILITY_EXTRA else 'Holiday', start, end)) + try: + start, end = self._ensure_dates_are_in_bounds(start, end) + except ValueError: + logger.debug('Holiday range does not overlap the availability range. Ignoring Holiday.') + return portion = self.availability[pd.date_range(start=start, end=end, freq=self.minimum_slot)] #select the specific range set_to = 1 if appointment_availability_or_holiday.kind == AVAILABILITY_EXTRA else 0 elif isinstance(appointment_availability_or_holiday, Appointment): start = appointment_availability_or_holiday.datetime_when end = start + datetime.timedelta(minutes=appointment_availability_or_holiday.length) logger.debug('Considering General Appointment from {} to {}'.format(start, end)) + try: + start, end = self._ensure_dates_are_in_bounds(start, end) + except ValueError: + logger.debug('Appointment range does not overlap the availability range. Ignoring Appointment.') + return portion = self.availability[pd.date_range(start=start, end=end, freq=self.minimum_slot)] #select the specific range set_to = 0 elif isinstance(appointment_availability_or_holiday, AppointmentTypeLink): start = appointment_availability_or_holiday.date_when end = start + datetime.timedelta(minutes=appointment_availability_or_holiday.appointment_type.default_duration) logger.debug('Considering Subject Appointment from {} to {}'.format(start, end)) + try: + start, end = self._ensure_dates_are_in_bounds(start, end) + except ValueError: + logger.debug('AppointmentTypeLink range does not overlap the availability range. Ignoring AppointmentTypeLink.') + return portion = self.availability[pd.date_range(start=start, end=end, freq=self.minimum_slot)] #select the specific range set_to = 0 else: diff --git a/smash/web/tests/test_office_availability.py b/smash/web/tests/test_office_availability.py index b1184285..69ac6790 100644 --- a/smash/web/tests/test_office_availability.py +++ b/smash/web/tests/test_office_availability.py @@ -108,6 +108,76 @@ class OfficeAvailabilityTest(TestCase): self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 59.900166389351085) # ((6*60)/(10*60.0+1))*100 # the border minute is 0 then no +1 self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 24.98265093684941) + def test_availability_cases2(self): + # + today = timezone.now() + start_date = datetime.datetime(today.year, today.month, today.day, tzinfo=today.tzinfo) #today midnight + end_date = start_date + datetime.timedelta(days=1) + + first_name = u'âêîôûŵŷäëïöüẅÿà ' + last_name = u'èìòùáºá»³Ã¡Ã©Ãóúẃýćńóśźżąę' + office_availability = OfficeAvailability(u'{} {}'.format(first_name, last_name), + start=start_date, end=end_date, office_start='8:00', office_end='18:00') + + #no availabilties added yet + self.assertEqual(office_availability.is_available(), False) + self.assertEqual(office_availability.is_available(only_working_hours=True), False) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 0.0) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 0.0) + + #add availabilty from 8:00 to 18:00 + weekday = list(get_weekdays_in_period(start_date, end_date))[0] + availability = create_availability(available_from='8:00', available_till='18:00', day_number=weekday) + office_availability.consider_this(availability) + self.assertEqual(office_availability.is_available(only_working_hours=True), True) + self.assertEqual(office_availability.is_available(), False) #less than 50% + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 100.0) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 41.70714781401804) # ((10*(60)+1)/(24*60.0+1))*100 That +1 is the zero minute + + #add holiday (leave) from 30 days ago up to next 90 days starting with today in the middle + start_date = datetime.datetime(today.year, today.month, today.day, 16, 00, tzinfo=today.tzinfo) + end_date = start_date + datetime.timedelta(days=90) + start_date = start_date - datetime.timedelta(days=30) + holiday = Holiday(person=create_worker(), datetime_start=start_date, datetime_end=end_date, kind=AVAILABILITY_HOLIDAY) + office_availability.consider_this(holiday) + self.assertEqual(office_availability.is_available(only_working_hours=True), False) + self.assertEqual(office_availability.is_available(), False) #less than 50% + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 0) # ((8*60)/(10*60.0+1))*100 # the bordwer minute is 0, then no +1 + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 0) # ((8*60)/(24*60.0+1))*100 + + def test_availability_cases3(self): + # + today = timezone.now() + start_date = datetime.datetime(today.year, today.month, today.day, tzinfo=today.tzinfo) #today midnight + end_date = start_date + datetime.timedelta(days=1) + + first_name = u'âêîôûŵŷäëïöüẅÿà ' + last_name = u'èìòùáºá»³Ã¡Ã©Ãóúẃýćńóśźżąę' + office_availability = OfficeAvailability(u'{} {}'.format(first_name, last_name), + start=start_date, end=end_date, office_start='8:00', office_end='18:00') + + #no availabilties added yet + self.assertEqual(office_availability.is_available(), False) + self.assertEqual(office_availability.is_available(only_working_hours=True), False) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 0.0) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 0.0) + #add availabilty from 8:00 to 18:00 + weekday = list(get_weekdays_in_period(start_date, end_date))[0] + availability = create_availability(available_from='8:00', available_till='18:00', day_number=weekday) + office_availability.consider_this(availability) + self.assertEqual(office_availability.is_available(only_working_hours=True), True) + self.assertEqual(office_availability.is_available(), False) #less than 50% + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 100.0) + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 41.70714781401804) # ((10*(60)+1)/(24*60.0+1))*100 That +1 is the zero minute + #add holiday (leave) from 90 days ago up to 60 days ago but without overlapping with today's date + start_date = datetime.datetime(today.year, today.month, today.day, 16, 00, tzinfo=today.tzinfo) - datetime.timedelta(days=90) + end_date = start_date + datetime.timedelta(days=30) + holiday = Holiday(person=create_worker(), datetime_start=start_date, datetime_end=end_date, kind=AVAILABILITY_HOLIDAY) + office_availability.consider_this(holiday) + self.assertEqual(office_availability.is_available(only_working_hours=True), True) + self.assertEqual(office_availability.is_available(), False) #less than 50% + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=True), 100.0) # ((8*60)/(10*60.0+1))*100 # the bordwer minute is 0, then no +1 + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 41.70714781401804) # ((8*60)/(24*60.0+1))*100 -- GitLab