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