diff --git a/smash/web/api_views/worker.py b/smash/web/api_views/worker.py index 39eb016eead7bbde9c4478276c82f27e39fd50cf..0a06231d775e41a1040b647549f89eb80615c614 100644 --- a/smash/web/api_views/worker.py +++ b/smash/web/api_views/worker.py @@ -1,12 +1,15 @@ import datetime - -from django.http import JsonResponse +import logging +import json +from django.http import JsonResponse, HttpResponse from django.utils import timezone +from django.shortcuts import get_object_or_404 from web.models.constants import GLOBAL_STUDY_ID from web.api_views.daily_planning import get_workers_for_daily_planning, get_availabilities from ..models import Worker +logger = logging.getLogger(__name__) def specializations(request): workers = Worker.objects.filter(specialization__isnull=False).values_list('specialization').distinct() @@ -21,14 +24,18 @@ def units(request): "units": [x[0] for x in workers] }) - def workers_for_daily_planning(request): + start_date = request.GET.get('start_date') workers = get_workers_for_daily_planning(request) workers_list_for_json = [] + if start_date is not None: + today = timezone.now() + start_date=datetime.datetime.strptime(start_date, '%Y-%m-%d').replace(tzinfo=today.tzinfo) for worker in workers: role = unicode(worker.roles.filter(study_id=GLOBAL_STUDY_ID)[0].role) worker_dict_for_json = { 'id': worker.id, + 'availability': worker.availability_percentage(start_date=start_date), 'title': u"{} ({})".format(unicode(worker), role[:1].upper()), 'role': role } diff --git a/smash/web/models/worker.py b/smash/web/models/worker.py index 58ecebabfc861ae1dbcec8e8b14e34bd71353d31..85037ed18446e0149e3e8ef8d417eb666cec6ab2 100644 --- a/smash/web/models/worker.py +++ b/smash/web/models/worker.py @@ -2,6 +2,8 @@ import datetime import logging +from web.utils import get_today_midnight_date + from django.contrib.auth.models import User, AnonymousUser from django.db import models @@ -9,6 +11,14 @@ from web.models.constants import GLOBAL_STUDY_ID, COUNTRY_OTHER_ID, AVAILABILITY from web.models.worker_study_role import STUDY_ROLE_CHOICES, HEALTH_PARTNER_ROLE_CHOICES, \ VOUCHER_PARTNER_ROLE_CHOICES, WORKER_STAFF, WORKER_HEALTH_PARTNER, WORKER_VOUCHER_PARTNER, ROLE_CHOICES +from web.utils import get_weekdays_in_period +from web.officeAvailability import OfficeAvailability +from django.db.models import Q +from web.models.holiday import Holiday +from web.models.availability import Availability +from web.models.appointment import Appointment +from web.models.appointment_type_link import AppointmentTypeLink + logger = logging.getLogger(__name__) @@ -159,6 +169,48 @@ class Worker(models.Model): else: return False + def is_available(self, start_date=None, end_date=None): + self.availability_percentage(start_date=start_date, end_date=end_date) > 50.0 + + def availability_percentage(self, start_date=None, end_date=None): + ''' + start_date: defaults to None and then is set to today's midnight date + end_date: defaults to None and then is set to today's midnight date + 24 hours + ''' + today_midnight = get_today_midnight_date() + + if start_date is None: + start_date = today_midnight + if end_date is None: + start_date = start_date.replace(hour=0, minute=0, second=0) + end_date = start_date + datetime.timedelta(days=1) + + 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) + + #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') + + #Availability + weekdays = get_weekdays_in_period(start_date, end_date) + weekdayQ = Q() #create a filter for each weekday in the selected period + for weekday in weekdays: + weekdayQ = weekdayQ | Q(day_number=weekday) + availabilities = self.availability_set.filter(person=self.id).filter(weekdayQ).order_by('day_number', 'available_from') + + things = [] + things.extend(availabilities) + things.extend(holidays_and_extra_availabilities) + things.extend(subject_appointments) + things.extend(general_appointments) + for thing in things: + office_availability.consider_this(thing, only_working_hours=True) + + return office_availability.get_availability_percentage(only_working_hours=True) + @property def role(self): roles = self.roles.filter(study=GLOBAL_STUDY_ID) diff --git a/smash/web/officeAvailability.py b/smash/web/officeAvailability.py new file mode 100644 index 0000000000000000000000000000000000000000..c4cf72f4c2a4d0ab27e86ba92c32458eb1f4be73 --- /dev/null +++ b/smash/web/officeAvailability.py @@ -0,0 +1,208 @@ +# coding=utf-8 +import datetime +from datetime import timedelta +import logging +import pandas as pd + +from web.utils import timeit, get_today_midnight_date +from web.models.holiday import Holiday +from web.models.availability import Availability +from web.models.appointment import Appointment +from web.models.appointment_type_link import AppointmentTypeLink +from web.models.constants import AVAILABILITY_EXTRA, AVAILABILITY_HOLIDAY + +logger = logging.getLogger(__name__) + +#only for plot method +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt + +class OfficeAvailability(object): + ''' + start: datetime-like indicating when the range starts. If none, then today midnight + end: datetime-like indicating when the range ends. If none, then tomorrow midnight + office_start: when the office hours begin + office_end: when the office hours finish + minimum_slot: frequency of the pandas series. T stands of minutes. Docs: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.date_range.html + ''' + def __init__(self, name, start=None, end=None, office_start='8:00', office_end='19:00', minimum_slot='1T'): + today_midnight = get_today_midnight_date() + tomorrow_midnight = today_midnight + datetime.timedelta(days=1) + + if start is None: + self.start = today_midnight + else: + self.start = start + + if end is None: + self.end = tomorrow_midnight + else: + self.end = end + + self.name = name + self.office_start = office_start + 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)) + self.availability = pd.Series(index=self.range, data=0) # initialize range at 0 + + def _get_duration(self): + return self.availability.index[-1] - self.availability.index[0] + + def add_availability(self, range, only_working_hours=False): + if only_working_hours: + range = range.to_series().between_time(self.office_start, self.office_end) + self.availability[range] = 1 + + def remove_availability(self, range, only_working_hours=False): + if only_working_hours: + range = range.to_series().between_time(self.office_start, self.office_end) + self.availability[range] = 0 + + def consider_this(self, appointment_availability_or_holiday, only_working_hours=False): + ''' + Availability repeat every week. + Availability always refers to a moment in which the worker should be working. Never the opposite. + + Holiday has higher preference because it refers to extraordinary events like extra availability or lack of availability. + Holiday modifies the status of Availability for specific periods of time. + + Only_working_hours: If true changed are limited to the provided working hours. + + Known Issues: If the range to be added extends beyond the limits of the given time series range, the call to self.availability[portion.index] = set_to will fail. + It fails because there are keys missing within the time series of the object. + + Two solutions are possible: + - First, limit the time periods of the ranges to be considered to the object time space. (current solution) + - Second, extend the object time space. + + Notwithstanding, this issue shouldn't exist because in preivous steps we should receive the availabilities queried to the limits of this objects time space. + First proposal should be the solution to consider. + ''' + if isinstance(appointment_availability_or_holiday, Availability): + start = appointment_availability_or_holiday.available_from + end = appointment_availability_or_holiday.available_till + weekday = appointment_availability_or_holiday.day_number + logger.debug('Considering Availability from {} to {} for weekday {}'.format(start, end, weekday)) + portion = self.availability[self.availability.index.weekday == (weekday-1)].between_time(start,end) #selects the weekdays and then the specific hours + set_to = 1 + elif isinstance(appointment_availability_or_holiday, Holiday): + start = appointment_availability_or_holiday.datetime_start + end = appointment_availability_or_holiday.datetime_end + logger.debug('Considering {} from {} to {}'.format('Extra Availability' if appointment_availability_or_holiday.kind == AVAILABILITY_EXTRA else 'Holiday', start, end)) + 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)) + 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)) + portion = self.availability[pd.date_range(start=start, end=end, freq=self.minimum_slot)] #select the specific range + set_to = 0 + else: + logger.error('Expected Holiday or Availability objects.') + raise TypeError + + if only_working_hours: + portion = portion.between_time(self.office_start, self.office_end) + + #limit portion to be changed to the bounds of the object time space (solution 1 of the aforementioned problem) + portion = portion[(self.availability.index.min() <= portion.index) & (portion.index <= self.availability.index.max())] + + self.availability[portion.index] = set_to + + def get_availability_percentage(self, only_working_hours=False): + ''' + For multiple values this is the solution: return self.availability.value_counts().div(len(s))[1] * 100 + But since it's 0 or 1, this works as well and is faster: return self.availability.mean() * 100 + + To test it: + import pandas as pd + range = pd.date_range(start='2018-10-1', end='2018-10-2 01:00', freq='5T', closed=None) + s = pd.Series(index=range, data=0) + range2 = pd.date_range(start='2018-10-1 1:00', end='2018-10-1 2:30', freq='5T') + s[range2] = 1 + print(s.value_counts().div(len(s))[1]*100) # prints 6.312292358803987 + print(s.mean()*100) # prints 6.312292358803987 + %timeit s.value_counts().div(len(s))[1]*100 # 504 µs ± 19.2 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) + %timeit s.mean()*100 # 56.3 µs ± 1.66 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each) + ''' + if only_working_hours: + availability = self.availability.between_time(self.office_start, self.office_end) + else: + availability = self.availability + + return availability.mean() * 100 #better to isolate the operation in case we change it later + + def is_availabile(self, only_working_hours=False): + ''' + Returns True if on the selected period is available at least 50% of the time + Otherwise returns False + ''' + return self.get_availability_percentage(only_working_hours=only_working_hours) > 50.0 + + @timeit + def plot_availability(self): + fig = plt.figure() #create new figure. This should ensure thread safe method + ax=fig.gca() #get current axes + matplotlib.rcParams['hatch.linewidth'] = 1 + logger.warn('business_hours: {} {}'.format(self.office_start, self.office_end)) + business_hours = self.business_hours = pd.Series(index=self.range, data=0) + mask = business_hours.between_time(self.office_start, self.office_end).index + business_hours[mask] = 1 + ax = business_hours.plot(kind='area', alpha = 0.33, color='#1190D8', label='Business Hours', legend=True, ax=ax) + + #calculate good xticks + hours = self._get_duration().total_seconds()/3600 + n_ticks = int(hours/24) + if n_ticks == 0: + minutes = self._get_duration().total_seconds()/60 + n_ticks = int(minutes/60) + if n_ticks == 0: + n_ticks = 1 + xticks=self.availability.asfreq('{}T'.format(n_ticks)).index + else: + xticks=self.availability.asfreq('{}H'.format(n_ticks)).index + + title = 'Availability for {} from {} to {}'.format(self.name, self.start.strftime('%Y/%m/%d %H:%M'), self.end.strftime('%Y/%m/%d %H:%M')) + + ax = self.availability.plot(figsize=(16, 8), grid = True, + title=title, legend=True, label='Availability', color='#00af52', + xticks=xticks, ax=ax, yticks=[0,1]) + + ax.fill_between(self.availability.index, self.availability.tolist(), facecolor="none", hatch='//', edgecolor="#00af52", alpha=1, linewidth=0.5) + ax.set_axisbelow(True) + ax.yaxis.grid(color='gray', linewidth=0.5, alpha=0) + ax.xaxis.grid(color='gray', linewidth=0.5, alpha=1) + ax.set_yticklabels(['False', 'True']) + ax.set_ylabel('Is Available ?') + ax.set_xlabel('Date & Time') + + fig.tight_layout() + fig.savefig('{}_{}_{}.pdf'.format(self.name, self.start.strftime('%Y%m%d%H%M'), self.end.strftime('%Y%m%d%H%M'))) + + + + + + + + + + + + + + + + + + + diff --git a/smash/web/static/js/daily_planning.js b/smash/web/static/js/daily_planning.js index 1f8265a095241a09db9545ac11048d80f4809320..3806eae42d06a0a1e2a35a9971939cc8fc27191a 100644 --- a/smash/web/static/js/daily_planning.js +++ b/smash/web/static/js/daily_planning.js @@ -368,7 +368,21 @@ $(document).ready(function () { droppable: true, resourceAreaWidth: '15%', resourceLabelText: 'Workers', - resources: resources_url, + refetchResourcesOnNavigate: true, + resourceOrder: '-availability', + resources: function(callback){ + setTimeout(function(){ + var view = $('#calendar').fullCalendar('getView'); + $.ajax({ + url: resources_url, + type: 'GET', + cache: false, + data: { + start_date: view.start.format('YYYY-MM-DD'), + } + }).then(function(resources){callback(resources)}); + }, 0); + }, events: [], eventRender: function (event, element) { if (event.rendering !== 'background') {