diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8cfd55f3ec6c0d9daf2a9017bd6295d33d66b10d..eec2748df839b102259fb156265a5ae78a83c3a0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -19,4 +19,4 @@ test: - cd smash - python manage.py makemigrations web && python manage.py migrate - coverage run --source web manage.py test - - coverage report -m --omit="*/test*,*/migrations*" + - coverage report -m --omit="*/test*,*/migrations*,*debug_utils*" diff --git a/requirements.txt b/requirements.txt index 95ab826f6bcb399e5f0f90d81945eff3cdbc584c..01f45824f0cbf1d4f885b222807a543dcaf2de59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +pandas==0.23.4 +numpy==1.15.2 +matplotlib==2.2.3 Django==1.11.5 gunicorn==19.6.0 Pillow==3.4.2 diff --git a/smash/web/api_urls.py b/smash/web/api_urls.py index 372aa370c6f24960f3122b1c18b24e8494fac9a0..61777145a4a8dac58738ad3164d1cb2e0f4d85f9 100644 --- a/smash/web/api_urls.py +++ b/smash/web/api_urls.py @@ -67,6 +67,9 @@ urlpatterns = [ url(r'^availabilities/(?P<date>\d{4}-\d{2}-\d{2})/$', daily_planning.availabilities, name='web.api.availabilities'), url(r'^events_persist$', daily_planning.events_persist, name='web.api.events_persist'), + #worker availability + url(r'^worker_availability/$', worker.get_worker_availability, name='web.api.get_worker_availability'), + # worker data url(r'^redcap/missing_subjects/(?P<missing_subject_id>\d+):ignore$', redcap.ignore_missing_subject, name='web.api.redcap.ignore_missing_subject'), diff --git a/smash/web/api_views/daily_planning.py b/smash/web/api_views/daily_planning.py index ec08fdcd5b6b83a46694dfe13e6aeb96e667ca62..773131ec056af9b6b55c919d2bcdcdb44e13c090 100644 --- a/smash/web/api_views/daily_planning.py +++ b/smash/web/api_views/daily_planning.py @@ -57,6 +57,7 @@ def get_holidays(worker, date): 'link_when': start_date, 'link_who': worker.id, 'link_end': end_date, + 'kind': holiday.kind } result.append(event) @@ -246,8 +247,9 @@ def events(request, date): subject = { 'name': unicode(appointment_subject), 'id': appointment_subject.id, + 'appointment_id': appointment.id, 'color': RANDOM_COLORS[i], - 'start': appointment.datetime_when.replace(tzinfo=None).strftime("%H:%M:00"), + 'start': appointment.datetime_when.replace(tzinfo=None).strftime("%H:%M"), # this indicates only location of the first appointment # (there is small chance to have two appointments in two different places at the same day) 'location': str(appointment.location), diff --git a/smash/web/api_views/worker.py b/smash/web/api_views/worker.py index 39eb016eead7bbde9c4478276c82f27e39fd50cf..836fa83d720fdcee65b532d1727f2621682d5608 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 } @@ -86,3 +93,31 @@ def serialize_worker(worker): "id": worker.id, } return result + +def get_worker_availability(request): + start_str_date = request.GET.get("start_date") + end_str_date = request.GET.get("end_date") + worker_id = request.GET.get("worker_id") + + if start_str_date is None or worker_id is None: + context = { + 'status': '400', 'reason': 'Either start_date, worker_id or both are invalid.' + } + response = HttpResponse(json.dumps(context), content_type='application/json') + response.status_code = 400 + return response + + start_date = datetime.datetime.strptime(start_str_date, "%Y-%m-%d-%H-%M").replace(tzinfo=timezone.now().tzinfo) + if end_str_date is None or end_str_date == start_str_date: + start_date = start_date.replace(hour=0, minute=0, second=0) + end_date = start_date + datetime.timedelta(days=1) + else: + end_date = datetime.datetime.strptime(end_str_date, "%Y-%m-%d-%H-%M").replace(tzinfo=timezone.now().tzinfo) + worker = get_object_or_404(Worker, id=int(worker_id)) + + result = { + 'start_date': start_date, + 'end_date': end_date, + 'availability': round(worker.availability_percentage(start_date=start_date, end_date=end_date), 0) + } + return JsonResponse(result) \ No newline at end of file diff --git a/smash/web/debug_utils.py b/smash/web/debug_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..fa04ebe448725534d015e406ff436b6e275f923b --- /dev/null +++ b/smash/web/debug_utils.py @@ -0,0 +1,19 @@ +# coding=utf-8 +import time + +def timeit(method): + ''' + Debug decorator to measure the execution time of some method or function + ''' + def timed(*args, **kw): + ts = time.time() + result = method(*args, **kw) + te = time.time() + if 'log_time' in kw: + name = kw.get('log_name', method.__name__.upper()) + kw['log_time'][name] = int((te - ts) * 1000) + else: + print '%r %2.2f ms' % \ + (method.__name__, (te - ts) * 1000) + return result + return timed \ No newline at end of file diff --git a/smash/web/forms/appointment_form.py b/smash/web/forms/appointment_form.py index 83b40597bc5d3ad21a7b5927e5a5c73e59004ff1..a832eb19da9f204ef55fe961d65e81bd0f5dad5b 100644 --- a/smash/web/forms/appointment_form.py +++ b/smash/web/forms/appointment_form.py @@ -115,6 +115,10 @@ class AppointmentAddForm(AppointmentForm): widget=forms.CheckboxSelectMultiple, queryset=AppointmentType.objects.all(), ) + fields['worker_assigned'].widget.attrs = {'class': 'search_worker_availability'} + fields['datetime_when'].widget.attrs = {'class': 'start_date'} + fields['length'].widget.attrs = {'class': 'appointment_duration'} + self.fields = fields self.fields['location'].queryset = get_filter_locations(self.user) diff --git a/smash/web/forms/forms.py b/smash/web/forms/forms.py index b426058577ffc456434243d69c8e61e94adcf8ac..3106fba657d935031fcf148a5cbe6c253dc6c9d2 100644 --- a/smash/web/forms/forms.py +++ b/smash/web/forms/forms.py @@ -123,6 +123,10 @@ class StatisticsForm(Form): class AvailabilityAddForm(ModelForm): + def __init__(self, *args, **kwargs): + super(AvailabilityAddForm, self).__init__(*args, **kwargs) + self.fields['person'].widget.attrs['readonly'] = True + available_from = forms.TimeField(label="Available from", widget=forms.TimeInput(TIMEPICKER_DATE_ATTRS), initial="8:00", @@ -204,6 +208,10 @@ class FlyingTeamEditForm(ModelForm): class HolidayAddForm(ModelForm): + def __init__(self, *args, **kwargs): + super(HolidayAddForm, self).__init__(*args, **kwargs) + self.fields['person'].widget.attrs['readonly'] = True + datetime_start = forms.DateTimeField(widget=forms.DateTimeInput(DATETIMEPICKER_DATE_ATTRS), initial=datetime.datetime.now().replace(hour=8, minute=0), ) diff --git a/smash/web/migrations/0119_auto_20181002_0908.py b/smash/web/migrations/0119_auto_20181002_0908.py new file mode 100644 index 0000000000000000000000000000000000000000..3f3a9a06ccbba72db9a92a1f4a682fbcf19318c0 --- /dev/null +++ b/smash/web/migrations/0119_auto_20181002_0908.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-02 09:08 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0118_voucher_activity_type'), + ] + + operations = [ + migrations.AlterField( + model_name='workerstudyrole', + name='role', + field=models.CharField(choices=[(b'DOCTOR', b'Doctor'), (b'NURSE', b'Nurse'), (b'PSYCHOLOGIST', b'Psychologist'), (b'TECHNICIAN', b'Technician'), (b'SECRETARY', b'Secretary'), (b'PROJECT MANAGER', b'Project Manager')], max_length=20, verbose_name=b'Role'), + ), + ] diff --git a/smash/web/migrations/0119_auto_20181015_1324.py b/smash/web/migrations/0119_auto_20181015_1324.py new file mode 100644 index 0000000000000000000000000000000000000000..3f4d4840a0ac7dbcf05d93adf550587cb1412581 --- /dev/null +++ b/smash/web/migrations/0119_auto_20181015_1324.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-15 13:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0118_voucher_activity_type'), + ] + + operations = [ + migrations.AlterField( + model_name='studyvisitlist', + name='type', + field=models.CharField(choices=[(b'UNFINISHED', b'Unfinished visits'), (b'APPROACHING_WITHOUT_APPOINTMENTS', b'Approaching visits'), (b'APPROACHING_FOR_MAIL_CONTACT', b'Post mail for approaching visits'), (b'GENERIC', b'Generic visit list'), (b'MISSING_APPOINTMENTS', b'Visits with missing appointments'), (b'EXCEEDED_TIME', b'Exceeded visit time')], max_length=50, verbose_name=b'Type of list'), + ), + migrations.AlterField( + model_name='workerstudyrole', + name='role', + field=models.CharField(choices=[(b'DOCTOR', b'Doctor'), (b'NURSE', b'Nurse'), (b'PSYCHOLOGIST', b'Psychologist'), (b'TECHNICIAN', b'Technician'), (b'SECRETARY', b'Secretary'), (b'PROJECT MANAGER', b'Project Manager')], max_length=20, verbose_name=b'Role'), + ), + ] diff --git a/smash/web/migrations/0120_holiday_kind.py b/smash/web/migrations/0120_holiday_kind.py new file mode 100644 index 0000000000000000000000000000000000000000..688d8f04cbc5fc25594949c5d8d436c4bf58cc86 --- /dev/null +++ b/smash/web/migrations/0120_holiday_kind.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-03 09:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0119_auto_20181002_0908'), + ] + + operations = [ + migrations.AddField( + model_name='holiday', + name='kind', + field=models.CharField(choices=[(b'H', b'Holiday'), (b'X', b'Extra Availability')], default=b'H', max_length=1), + ), + ] diff --git a/smash/web/migrations/0121_auto_20181003_1256.py b/smash/web/migrations/0121_auto_20181003_1256.py new file mode 100644 index 0000000000000000000000000000000000000000..a350af1604c22d5f97a0f6290a56ce2920c33155 --- /dev/null +++ b/smash/web/migrations/0121_auto_20181003_1256.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-03 12:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0120_holiday_kind'), + ] + + operations = [ + migrations.AlterField( + model_name='holiday', + name='kind', + field=models.CharField(choices=[(b'H', b'Holiday'), (b'X', b'Extra Availability')], default=b'H', help_text=b'Defines the kind of availability. Either Holiday or Extra Availability.', max_length=1), + ), + ] diff --git a/smash/web/migrations/0122_remove_worker_appointments.py b/smash/web/migrations/0122_remove_worker_appointments.py new file mode 100644 index 0000000000000000000000000000000000000000..b2df6305c497a1a49f0ac64d872f67c3c9d75d29 --- /dev/null +++ b/smash/web/migrations/0122_remove_worker_appointments.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-10 12:29 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0121_auto_20181003_1256'), + ] + + operations = [ + migrations.RemoveField( + model_name='worker', + name='appointments', + ), + ] diff --git a/smash/web/migrations/0123_merge_20181017_1532.py b/smash/web/migrations/0123_merge_20181017_1532.py new file mode 100644 index 0000000000000000000000000000000000000000..a805cf53f1733f6e230ff0e1b5012f08cb1dc0b2 --- /dev/null +++ b/smash/web/migrations/0123_merge_20181017_1532.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-17 15:32 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0119_auto_20181015_1324'), + ('web', '0122_remove_worker_appointments'), + ] + + operations = [ + ] diff --git a/smash/web/migrations/0124_auto_20181017_1532.py b/smash/web/migrations/0124_auto_20181017_1532.py new file mode 100644 index 0000000000000000000000000000000000000000..8ebc0e3b07022640ba756f344dc72f499b3ce28a --- /dev/null +++ b/smash/web/migrations/0124_auto_20181017_1532.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2018-10-17 15:32 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web', '0123_merge_20181017_1532'), + ] + + operations = [ + migrations.AlterField( + model_name='studyvisitlist', + name='type', + field=models.CharField(choices=[(b'UNFINISHED', b'unfinished visits'), (b'APPROACHING_WITHOUT_APPOINTMENTS', b'approaching visits'), (b'APPROACHING_FOR_MAIL_CONTACT', b'post mail for approaching visits'), (b'GENERIC', b'Generic visit list'), (b'MISSING_APPOINTMENTS', b'visits with missing appointments'), (b'EXCEEDED_TIME', b'exceeded visit time')], max_length=50, verbose_name=b'Type of list'), + ), + ] diff --git a/smash/web/models/constants.py b/smash/web/models/constants.py index ad7354a0bda27a3c369369e0425657fa6499e708..3257a992b3dae412e271019a38e5342855acae0e 100644 --- a/smash/web/models/constants.py +++ b/smash/web/models/constants.py @@ -78,6 +78,14 @@ WEEKDAY_CHOICES = ( (SUNDAY_AS_DAY_OF_WEEK, 'SUNDAY'), ) +AVAILABILITY_HOLIDAY = 'H' +AVAILABILITY_EXTRA = 'X' + +AVAILABILITY_CHOICES = ( + (AVAILABILITY_HOLIDAY, 'Holiday'), + (AVAILABILITY_EXTRA, 'Extra Availability'), +) + REDCAP_TOKEN_CONFIGURATION_TYPE = "REDCAP_TOKEN_CONFIGURATION_TYPE" REDCAP_BASE_URL_CONFIGURATION_TYPE = "REDCAP_BASE_URL_CONFIGURATION_TYPE" diff --git a/smash/web/models/holiday.py b/smash/web/models/holiday.py index 01cceaeadf3c442b6f6bb51874c47e682aae1072..927daa54340fd643e7ddd81a8f79e701238dbd7e 100644 --- a/smash/web/models/holiday.py +++ b/smash/web/models/holiday.py @@ -1,6 +1,7 @@ # coding=utf-8 from django.db import models +from constants import AVAILABILITY_CHOICES, AVAILABILITY_HOLIDAY class Holiday(models.Model): class Meta: @@ -21,6 +22,8 @@ class Holiday(models.Model): verbose_name='Comments' ) + kind = models.CharField(max_length=1, choices=AVAILABILITY_CHOICES, default=AVAILABILITY_HOLIDAY, help_text='Defines the kind of availability. Either Holiday or Extra Availability.') + def __str__(self): return "%s %s" % (self.person.first_name, self.person.last_name) diff --git a/smash/web/models/worker.py b/smash/web/models/worker.py index 86e2304dd544cadf224bec751e49b62f874301e6..85037ed18446e0149e3e8ef8d417eb666cec6ab2 100644 --- a/smash/web/models/worker.py +++ b/smash/web/models/worker.py @@ -2,13 +2,23 @@ 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 -from web.models.constants import GLOBAL_STUDY_ID, COUNTRY_OTHER_ID +from web.models.constants import GLOBAL_STUDY_ID, COUNTRY_OTHER_ID, AVAILABILITY_HOLIDAY, AVAILABILITY_EXTRA 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__) @@ -61,9 +71,6 @@ class Worker(models.Model): verbose_name='Locations', blank=True ) - appointments = models.ManyToManyField('web.Appointment', blank=True, - verbose_name='Appointments' - ) user = models.OneToOneField(User, blank=True, null=True, verbose_name='Username' ) @@ -141,7 +148,8 @@ class Worker(models.Model): def is_on_leave(self): if len(self.holiday_set.filter(datetime_end__gt=datetime.datetime.now(), - datetime_start__lt=datetime.datetime.now())): + datetime_start__lt=datetime.datetime.now(), + kind=AVAILABILITY_HOLIDAY)): return True return False @@ -161,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..1ee732561d60b4351b67a9e4539d6622e3fbb167 --- /dev/null +++ b/smash/web/officeAvailability.py @@ -0,0 +1,225 @@ +# coding=utf-8 +import datetime +from datetime import timedelta +import logging +import pandas as pd +from web.utils import 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='18: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): + ''' + Private method. Returns the differ + ''' + return self.availability.index[-1] - self.availability.index[0] + + def add_availability(self, range, only_working_hours=False): + ''' + Receives a pandas date_range `pd.date_range` object. + Sets the availability to one for the specific interval of the provided range. + ''' + range = range.round(self.minimum_slot) + if only_working_hours: + range = range.to_series().between_time(self.office_start, self.office_end).index + self.availability[range] = 1 + + def remove_availability(self, range, only_working_hours=False): + ''' + Receives a pandas date_range `pd.date_range` object. + Sets the availability to zero for the specific interval of the provided range. + ''' + range = range.round(self.minimum_slot) + if only_working_hours: + range = range.to_series().between_time(self.office_start, self.office_end).index + self.availability[range] = 0 + + 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. + :only_working_hours if true, only consider the defined working hours + + 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 Availability, Holiday, Appointment or AppointmentTypeLink 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_available(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 + + def plot_availability(self): + ''' + Plot availability chart. + ''' + fig = plt.figure() #create new figure. This should ensure thread safe method + ax=fig.gca() #get current axes + matplotlib.rcParams['hatch.linewidth'] = 1 + logger.debug('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 0febcc3c23bb664a06691491f767e0ec8c31bf38..9ae40d26537594521ff1412d448d72862255ad31 100644 --- a/smash/web/static/js/daily_planning.js +++ b/smash/web/static/js/daily_planning.js @@ -106,7 +106,11 @@ function get_subjects_events(day) { }); var holidays = data.holidays; $.each(holidays, function (index, event) { - event.backgroundColor = '#FFAAAA !important'; + if(event.kind == 'H'){ + event.backgroundColor = '#FFAAAA !important'; + }else{ + event.backgroundColor = '#AAFFAA !important'; + } event.start = $.fullCalendar.moment(event.link_when); event.end = $.fullCalendar.moment(event.link_end); event.rendering = 'background'; @@ -120,8 +124,10 @@ function get_subjects_events(day) { var boxSubject = $("<div class='box box-primary'/>").css('border-top-color', subject.color); var boxBody = $("<div class='box-body' id='subject_" + subject.id + "'>"); var boxHeader = $("<div class='box-header with-border'/>"); - var title_subject = $("<h4>" + subject.name + "( " + subject.start + ") <span style='float:right;padding-right:5px;'>" + subject.location + "</span></h4>"); + + var title_subject = $(`<h4>${subject.name} (${subject.start}) <span style='float:right;padding-right:5px;'>${subject.location}</span> <span style='float:left;padding-right:5px;'><a title="Edit appointment" target="_blank" href="/appointments/edit/${subject.appointment_id}"><i class="fa fa-pencil-square"></i></a></span></h4>`); boxHeader.append(title_subject); + title_subject.find('a[title]').tooltip(); $.each(subject.events, function (index_event, event) { if (event.link_when) { event.title = event.short_title; @@ -159,8 +165,9 @@ function get_subjects_events(day) { var boxSubject = $("<div class='box box-primary'/>").css('border-top-color', location.color); var boxBody = $("<div class='box-body' id='location_" + location.id + "'>"); var boxHeader = $("<div class='box-header with-border'/>"); - var title_location = $("<h4>" + location.name + " <span style='float:right;padding-right:5px;'>" + location.location + "</span></h4>"); + var title_location = $(`<h4>${location.name}<span style='float:right;padding-right:5px;'>${location.location}</span><span style='float:left;padding-right:5px;'><a title="Edit appointment" target="_blank" href="/appointments/edit/${location.id}"><i class="fa fa-pencil-square"></i></a></span></h4>`); boxHeader.append(title_location); + title_location.find('a[title]').tooltip(); $.each(location.events, function (index_event, event) { if (event.link_who) { event.title = event.short_title; @@ -287,7 +294,6 @@ $(document).ready(function () { appointmentsCleared.splice(index, 1); } } - } }); $.post({ @@ -363,10 +369,27 @@ $(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){ + var checked_roles = $('.role_list_item > input:checked').map( (i,e) => e.value).toArray(); + resources = resources.filter(resource => checked_roles.includes(resource.role)); + callback(resources) + }); + }, 0); + }, events: [], eventRender: function (event, element) { - if (event.rendering !== 'background') { var content = element.popover({ @@ -383,7 +406,6 @@ $(document).ready(function () { html: true }); } else { - } }, selectAllow: function (selectInfo) { @@ -403,7 +425,41 @@ $(document).ready(function () { selectHelper: true, drop: function (date, jsEvent, ui, resourceId) { $(this).remove(); + }, + eventAfterAllRender: function(view){ + //RESIZE COLUMNS AND ENABLE HORIZONTAL SCROLL + window.onresize = function(event) { + resizeCalendarColumns(); + }; + + //ADD EDIT BUTTONS + $('.fc-resource-cell').not('.anchored').each(function(resourceColumn){ + $(this).addClass('anchored'); + var worker_id = $(this).data('resource-id'); + var span = $(`<div style='display:block;'></div>`); + var add_extra_availability_link = `<a style="padding-right:5px;" title="Add Extra Availability or Holiday" target="_blank" href="/doctors/${worker_id}/holiday/add"><i class="fa fa-plus-square-o" aria-hidden="true"></i></a>`; + var edit_worker_link = `<a style="padding-right:5px;" title="Edit Worker" target="_blank" href="/doctors/edit/${worker_id}"><i class="fa fa-pencil-square" aria-hidden="true"></i></a>`; + + $(span).append($(add_extra_availability_link)); + $(span).append($(edit_worker_link)); + $(this).append(span); + //$(this).css('padding-left', span.width()); + $(this).find('a[title]').tooltip(); + }); + } }); }) -; \ No newline at end of file +; + +//RESIZE COLUMNS AND ENABLE HORIZONTAL SCROLL +function resizeCalendarColumns(){ + if($('.fc-resource-cell').width() <= 150){ + $('.fc-view-container').width(200*$('.fc-resource-cell').length); + $('#calendar').css('overflow-x', 'scroll'); + } + if($('#calendar').width() > 200*$('.fc-resource-cell').length){ + $('.fc-view-container').width("100%"); + $('#calendar').css('overflow-x', 'null'); + } +} diff --git a/smash/web/templates/appointments/add.html b/smash/web/templates/appointments/add.html index b29fc125c36b8e753afa41b7067714f4132f7f2f..bf87695b3f1f00caf74b6db18777f2ec762445be 100644 --- a/smash/web/templates/appointments/add.html +++ b/smash/web/templates/appointments/add.html @@ -13,10 +13,22 @@ {% include "includes/datetimepicker.css.html" %} <link rel="stylesheet" href="{% static 'css/appointment.css' %}"> + <style type="text/css"> + .availability_description { + text-align: center !important; + margin: 0; + } + </style> {% endblock styles %} {% block ui_active_tab %}'appointments'{% endblock ui_active_tab %} -{% block page_header %}New appointment{% endblock page_header %} +{% block page_header %} +{% if isGeneral %} +New general appointment +{% else %} +New appointment for visit from {{visit_start}} to {{visit_end}} +{% endif %} +{% endblock page_header %} {% block page_description %}{% endblock page_description %} {% block title %}{{ block.super }} - Add new appointment{% endblock %} @@ -101,8 +113,8 @@ "info": true, "autoWidth": false }); - $('#calendar').fullCalendar({ + defaultDate: moment('{{visit_start}}'), header: { left: 'prev,next today', center: 'title', @@ -117,7 +129,7 @@ dateString = dateString + " 09:00"; } document.getElementById("id_datetime_when").value = dateString; - + getWorkerAvailability(); }, eventClick: function (calEvent, jsEvent, view) { @@ -142,6 +154,66 @@ appointment_type_behaviour($("input[name='appointment_types']"), lengthInput, "{% url 'web.api.appointment_types' %}"); appointment_flying_team_place_behaviour($("select[name='flying_team']"), $("select[name='location']")); appointment_date_change_behaviour($("input[name='datetime_when']"), $("select[name='worker_assigned']"), lengthInput); + + function getWorkerAvailability() { + var selected = $('select.search_worker_availability').find(':selected'); + var worker_id = selected.val(); + if(worker_id===''){ + return; + } + + var start_date = $('input[name="datetime_when"]').val(); + start_date = moment(start_date); + if(isNaN(start_date)){ + return; + } + var ordinalDay = start_date.format('Do'); + var longStartDate = start_date.format('MMM Do HH:mm'); + + $('.availability_description').remove(); + + //GET FULL DAY AVAILABILITY + $.ajax({ + data: { + // our hypothetical feed requires UNIX timestamps + start_date: start_date.format('YYYY-MM-DD-HH-mm'), + worker_id: worker_id + }, + url: "{% url 'web.api.get_worker_availability' %}", + success: function (doc) { + $('select.search_worker_availability').parent().append(`<p class="availability_description">${ordinalDay} availability: ${doc.availability}%</p>`); + } + }); + + var duration = parseInt($('input[name="length"]').val()); + if(isNaN(duration)){ + return; + } + + var end_date = start_date.clone().add(duration, 'minutes'); + var endTime = end_date.format('HH:mm'); + + //GET SPECIFIC AVAILABILITY + $.ajax({ + data: { + // our hypothetical feed requires UNIX timestamps + start_date: start_date.format('YYYY-MM-DD-HH-mm'), + end_date: end_date.format('YYYY-MM-DD-HH-mm'), + worker_id: worker_id + }, + url: "{% url 'web.api.get_worker_availability' %}", + success: function (doc) { + if(doc.availability<100){ + var available = 'No'; + }else{ + var available = 'Yes'; + } + $('select.search_worker_availability').parent().append(`<p class="availability_description">${longStartDate} to ${endTime} availability: ${available} (${doc.availability}%)</p>`); + } + }); + } + + $('select.search_worker_availability, input[name="datetime_when"], input[name="length"]').on("change", getWorkerAvailability); </script> {% include "includes/datetimepicker.js.html" %} diff --git a/smash/web/templates/daily_planning.html b/smash/web/templates/daily_planning.html index c02b5d7d6e7e9262b53a20f90b1eb9c4d4a261d9..c28fb0192e2eaf4f53d787b3ba30d111ec7b6c8b 100644 --- a/smash/web/templates/daily_planning.html +++ b/smash/web/templates/daily_planning.html @@ -13,6 +13,20 @@ /> <link rel="stylesheet" href="{% static 'fullcalendar-scheduler/scheduler.min.css' %}"> <link rel="stylesheet" href="{% static 'css/daily_planning.css' %}"> + <style type="text/css"> + .role_label{ + padding-left: 5px; + } + #role_list{ + columns: 3; + webkit-columns: 2; + -moz-columns: 2; + } + .role_list_item{ + list-style: none; + list-style-type:none; + } + </style> {% include "includes/datepicker.css.html" %} {% endblock styles %} @@ -38,6 +52,13 @@ </div> </div> </div> + <ul id="role_list"> + {% for role in worker_study_roles %} + <li class="role_list_item"> + <input type="checkbox" name="{{role.0}}" value="{{role.0}}" id="{{role.0}}" onclick="clicked_role_list_item(this);" checked><label class="role_label" for="{{role.0}}">{{role.1}}</label> + </li> + {% endfor %} + </ul> {% endblock maincontent %} {% block scripts %} @@ -48,6 +69,11 @@ <script> var resources_url = '{% url 'web.api.workers.daily_planning' %}'; var events_url = '{% url 'web.api.events_persist' %}'; + function clicked_role_list_item(item){ + //var checked = $(item).prop('checked'); + $('#calendar').fullCalendar('refetchResources'); + } + </script> {% include "includes/datepicker.js.html" %} <script src="{% static 'js/daily_planning.js' %}"></script> diff --git a/smash/web/templates/doctors/add_availability.html b/smash/web/templates/doctors/add_availability.html index de6dce582dd2ea09ae2faf06b1d29bf4c21d0e03..90c093ca963bb776b32db6201c449dbf7c8b40f1 100644 --- a/smash/web/templates/doctors/add_availability.html +++ b/smash/web/templates/doctors/add_availability.html @@ -8,7 +8,7 @@ {% endblock styles %} {% block ui_active_tab %}'workers'{% endblock ui_active_tab %} -{% block page_header %}New worker availability{% endblock page_header %} +{% block page_header %}Create new availability for <span class="doctor_name">{{ doctor_name }}</span>{% endblock page_header %} {% block page_description %}{% endblock page_description %} {% block title %}{{ block.super }} - Add availability{% endblock %} diff --git a/smash/web/templates/doctors/add_holiday.html b/smash/web/templates/doctors/add_holiday.html index 59e2975087f29fa7adf00f386bf03623c09966f5..4a05cfda35db0ed77a11100c16a0946969085948 100644 --- a/smash/web/templates/doctors/add_holiday.html +++ b/smash/web/templates/doctors/add_holiday.html @@ -6,10 +6,15 @@ {{ block.super }} {% include "includes/datetimepicker.css.html" %} <link rel="stylesheet" href="{% static 'AdminLTE/plugins/awesomplete/awesomplete.css' %}"/> + <style type="text/css"> + .doctor_name { + border-bottom: black 1px solid; + } + </style> {% endblock styles %} {% block ui_active_tab %}'workers'{% endblock ui_active_tab %} -{% block page_header %}New worker holiday{% endblock page_header %} +{% block page_header %}Create new holiday or extra availability for <span class="doctor_name">{{ doctor_name }}</span>{% endblock page_header %} {% block page_description %}{% endblock page_description %} {% block title %}{{ block.super }} - Add availability{% endblock %} @@ -36,8 +41,10 @@ <div class="form-group {% if field.errors %}has-error{% endif %}"> <label for="{# TODO #}" class="col-sm-4 control-label"> {{ field.label }} + {% if field.help_text %} + <i class="fa fa-info-circle" aria-hidden="true" data-toggle="tooltip" data-placement="top" title="{{field.help_text}}"></i> + {% endif %} </label> - <div class="col-sm-8"> {{ field|add_class:'form-control' }} </div> diff --git a/smash/web/templates/doctors/breadcrumb.html b/smash/web/templates/doctors/breadcrumb.html index 549b2357d5ee10f1a13a761f2cd3548d54c8bc29..6d111dd545adc0d422fe4dfae996671af3c8f657 100644 --- a/smash/web/templates/doctors/breadcrumb.html +++ b/smash/web/templates/doctors/breadcrumb.html @@ -1,2 +1,2 @@ <li><a href="{% url 'web.views.appointments' %}"><i class="fa fa-dashboard"></i> Dashboard</a></li> -<li class="active"><a href="{% url 'web.views.workers' worker_type %}">Workers</a></li> \ No newline at end of file +<li class="active"><a href="{% url 'web.views.workers' %}">Workers</a></li> \ No newline at end of file diff --git a/smash/web/templates/doctors/edit.html b/smash/web/templates/doctors/edit.html index c8435067c97ba02e96a5bec0735a6ed752bbe04b..fb689494e717f705c657633b0dc42508d0968e22 100644 --- a/smash/web/templates/doctors/edit.html +++ b/smash/web/templates/doctors/edit.html @@ -117,7 +117,7 @@ </div> <div class="box-header with-border"> - <h3 class="box-title">Holidays</h3> + <h3 class="box-title">Holidays and Extra Availabilities</h3> </div> <div class="box-body"> <table id="table" class="table table-bordered table-striped"> @@ -125,6 +125,7 @@ <tr> <th>From</th> <th>Until</th> + <th>Kind</th> <th>Info</th> <th>Remove</th> </tr> @@ -134,6 +135,13 @@ <tr> <td>{{ holiday.datetime_start }}</td> <td>{{ holiday.datetime_end }}</td> + <td> + {% for kind in availability_choices %} + {% if holiday.kind == kind.0 %} + {{ kind.1 }} + {% endif %} + {% endfor %} + </td> <td>{{ holiday.info }}</td> <td> <a href="{% url 'web.views.worker_holiday_delete' holiday.id %}" type="button" @@ -148,7 +156,7 @@ <div class="box-footer"> <div class="col-sm-6"> <a href="{% url 'web.views.worker_holiday_add' doctor_id %}" type="button" - class="btn btn-block btn-success">Add holiday</a> + class="btn btn-block btn-success">Add Holiday or Extra Availability</a> </div> </div><!-- /.box-footer --> diff --git a/smash/web/templates/doctors/index.html b/smash/web/templates/doctors/index.html index 93d2bf58d6a0e86f891afa629cab2fadd74b8459..1accfd94d238279684ff6d4fe11d6085b05976b8 100644 --- a/smash/web/templates/doctors/index.html +++ b/smash/web/templates/doctors/index.html @@ -8,7 +8,15 @@ {% endblock styles %} {% block ui_active_tab %}'workers'{% endblock ui_active_tab %} - +{% block page_header %} +{% if worker_type == 'STAFF' %} + Workers +{% elif worker_type == 'HEALTH_PARTNER' %} + Health Partners +{% elif worker_type == 'VOUCHER_PARTNER' %} + Voucher Partners +{% endif %} +{% endblock page_header %} {% block breadcrumb %} {% include "doctors/breadcrumb.html" %} {% endblock breadcrumb %} diff --git a/smash/web/templates/visits/details.html b/smash/web/templates/visits/details.html index ed73408d2e1030b164c7a6e38be41df5b83c9fb8..62069efad065578c2615da6a13a554e261b11629 100644 --- a/smash/web/templates/visits/details.html +++ b/smash/web/templates/visits/details.html @@ -126,7 +126,7 @@ <td>{{ app.datetime_when | time:"H:i" }}</td> <td>{{ app.length }}</td> <td> - {% if app.flying_team %}{{ app.worker_assigned.first_name }} + {% if app.flying_team is None %}{{ app.worker_assigned.first_name }} {{ app.worker_assigned.last_name }} {% else %} {{ app.flying_team }} {% endif %} diff --git a/smash/web/tests/api_views/test_worker.py b/smash/web/tests/api_views/test_worker.py index c146d560acf741436cef1ba93667a994940d8fdb..9b759f0cfcee629968a1dc2732ffe34406a20d11 100644 --- a/smash/web/tests/api_views/test_worker.py +++ b/smash/web/tests/api_views/test_worker.py @@ -1,6 +1,6 @@ # coding=utf-8 import json - +import datetime from django.test import RequestFactory from django.urls import reverse @@ -54,6 +54,13 @@ class TestWorkerApi(LoggedInWithWorkerTestCase): self.assertEqual(response.status_code, 200) self.assertTrue(self.worker.first_name in response.content) + def test_workers_for_daily_planning_with_start_date(self): + today = datetime.datetime.today() + start_date = today.strftime("%Y-%m-%d") + params = {'start_date': start_date} + response = self.client.get(reverse('web.api.workers.daily_planning'), data=params) + self.assertEqual(response.status_code, 200) + def test_voucher_partners(self): voucher_partner = create_voucher_partner() response = self.client.get(reverse('web.api.workers', kwargs={'worker_role': WORKER_STAFF})) @@ -85,3 +92,32 @@ class TestWorkerApi(LoggedInWithWorkerTestCase): for entry in entries: count += len(entry["workers"]) self.assertTrue(count > 0) + def test_get_worker_availability(self): + today = datetime.datetime.today().replace(hour=8, minute=00, second=0, microsecond=0) + availability = Availability.objects.create(person=self.worker, day_number=today.isoweekday(), + available_from="8:00", available_till="16:00") + availability.save() + params={} + params['start_date'] = today.strftime("%Y-%m-%d-%H-%M") + params['end_date'] = (today+datetime.timedelta(hours=4)).strftime("%Y-%m-%d-%H-%M") + params['worker_id'] = self.worker.id + response = self.client.get(reverse('web.api.get_worker_availability'), data=params) + availability = json.loads(response.content)['availability'] + self.assertEqual(availability, 100.0) + today = today.replace(hour=16, minute=1) + params={} + params['start_date'] = today.strftime("%Y-%m-%d-%H-%M") + params['end_date'] = (today+datetime.timedelta(hours=4)).strftime("%Y-%m-%d-%H-%M") + params['worker_id'] = self.worker.id + response = self.client.get(reverse('web.api.get_worker_availability'), data=params) + availability = json.loads(response.content)['availability'] + self.assertEqual(availability, 0.0) + today = today.replace(hour=14, minute=0) + params={} + params['start_date'] = today.strftime("%Y-%m-%d-%H-%M") + params['end_date'] = (today+datetime.timedelta(hours=4)).strftime("%Y-%m-%d-%H-%M") + params['worker_id'] = self.worker.id + response = self.client.get(reverse('web.api.get_worker_availability'), data=params) + availability = json.loads(response.content)['availability'] + self.assertEqual(availability, 50.0) + \ No newline at end of file diff --git a/smash/web/tests/functions.py b/smash/web/tests/functions.py index 3af08171a7a08d4b512468666240737fd8fd0ef2..ed17deb7e769ffdb6125cb317302840c934b29e6 100644 --- a/smash/web/tests/functions.py +++ b/smash/web/tests/functions.py @@ -154,13 +154,13 @@ def get_test_study(): return create_study("test-study") -def create_appointment_type(): +def create_appointment_type(code='C', default_duration=10, description='test'): return AppointmentType.objects.create( - code="C", - default_duration="10", - description="test", + code=code, + default_duration=default_duration, + description=description, ) - + def create_contact_attempt(subject=None, worker=None): if subject is None: @@ -259,34 +259,43 @@ def create_voucher_partner(): return worker -def create_availability(worker=None): +def create_availability(worker=None, available_from=None, available_till=None, day_number=MONDAY_AS_DAY_OF_WEEK): + if available_from is None: + available_from = '8:00' + if available_till is None: + available_till = '18:00' + if worker is None: worker = create_worker() availability = Availability.objects.create(person=worker, - day_number=MONDAY_AS_DAY_OF_WEEK, - available_from=get_today_midnight_date(), - available_till=get_today_midnight_date(), + day_number=day_number, + available_from=available_from, + available_till=available_till, ) return availability -def create_visit(subject=None): +def create_visit(subject=None, datetime_begin=None, datetime_end=None): if subject is None: subject = create_study_subject() - return Visit.objects.create(datetime_begin=get_today_midnight_date() + datetime.timedelta(days=-31), - datetime_end=get_today_midnight_date() + datetime.timedelta(days=31), + if datetime_begin is None: + datetime_begin = get_today_midnight_date() + datetime.timedelta(days=-31) + if datetime_end is None: + datetime_end = get_today_midnight_date() + datetime.timedelta(days=31) + return Visit.objects.create(datetime_begin=datetime_begin, + datetime_end=datetime_end, subject=subject, is_finished=False) -def create_appointment(visit=None, when=None): +def create_appointment(visit=None, when=None, length=30): if visit is None: visit = create_visit() # if when is None: # when = get_today_midnight_date() return Appointment.objects.create( visit=visit, - length=30, + length=length, location=get_test_location(), status=Appointment.APPOINTMENT_STATUS_SCHEDULED, datetime_when=when) diff --git a/smash/web/tests/test_office_availability.py b/smash/web/tests/test_office_availability.py new file mode 100644 index 0000000000000000000000000000000000000000..b1184285f2c0c768d27ce8874f3cf776f474f9d0 --- /dev/null +++ b/smash/web/tests/test_office_availability.py @@ -0,0 +1,113 @@ +import logging + +from django.test import TestCase +from django.utils import timezone +import datetime +import pandas as pd +from datetime import timedelta +from functions import create_availability, create_visit, create_appointment, create_appointment_type +from web.utils import get_weekdays_in_period +from web.officeAvailability import OfficeAvailability +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_HOLIDAY, AVAILABILITY_EXTRA +from web.tests.functions import create_worker + +logger = logging.getLogger(__name__) + +class OfficeAvailabilityTest(TestCase): + def test_availability_cases(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) + + office_availability = OfficeAvailability('FirstName LastName', + 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 from 16:00 to 18:00 # 2 hours less availability + start_date = datetime.datetime(today.year, today.month, today.day, 16, 00, tzinfo=today.tzinfo) + end_date = start_date + datetime.timedelta(hours=2) + 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), 79.86688851913478) # ((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), 33.31020124913255) # ((8*60)/(24*60.0+1))*100 + + #add extra availability from 17:00 to 18:00 # 1 hour more availability + start_date = datetime.datetime(today.year, today.month, today.day, 17, 00, tzinfo=today.tzinfo) + end_date = start_date + datetime.timedelta(hours=1) + extra_availability = Holiday(person=create_worker(), datetime_start=start_date, datetime_end=end_date, kind=AVAILABILITY_EXTRA) + office_availability.consider_this(extra_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), 90.01663893510815) # ((9*60+1)/(10*60.0+1))*100 # the border minute is now 1 then +1 + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 37.543372657876475) + + #add appointment from 9:00 to 10:00 # 1 hour less availability + #create visit + visit_start_date = start_date - datetime.timedelta(days=31) + visit_end_date = end_date + datetime.timedelta(days=31) + visit = create_visit(subject=None, datetime_begin=visit_start_date, datetime_end=visit_end_date) + #create appointment + appointment_typeA = create_appointment_type(code='A', default_duration=40, description='test1') + appointment_typeB = create_appointment_type(code='B', default_duration=20, description='test2') + appointment_when = datetime.datetime(today.year, today.month, today.day, 9, 00, tzinfo=today.tzinfo) + appointment = create_appointment(visit=visit, when=appointment_when, length=60) + worker = create_worker() + app_type_linkA = AppointmentTypeLink(appointment=appointment, date_when=appointment_when, appointment_type=appointment_typeA, worker=worker) + app_type_linkB = AppointmentTypeLink(appointment=appointment, date_when=appointment_when+datetime.timedelta(minutes=20), appointment_type=appointment_typeB, worker=worker) + appointment.save() + #the availability percentage should be the same as before adding the extra availability + office_availability.consider_this(appointment) + 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), 79.86688851913478) # ((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), 33.31020124913255) # ((8*60)/(24*60.0+1))*100 + #consider the 2 AppointmentTypeLinks. The availability percentage shouldn't change + office_availability.consider_this(app_type_linkA) + office_availability.consider_this(app_type_linkB) + 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), 79.86688851913478) # ((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), 33.31020124913255) # ((8*60)/(24*60.0+1))*100 + + #force add availability from 9:00 to 10:00 # 1 hour more availability + start_date = datetime.datetime(today.year, today.month, today.day, 9, 00, tzinfo=today.tzinfo).replace(second=0, microsecond=0) + end_date = start_date + datetime.timedelta(hours=1) + office_availability.add_availability(pd.date_range(start=start_date, end=end_date, freq='1T'), only_working_hours=False) + 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), 90.01663893510815) # ((9*60+1)/(10*60.0+1))*100 # the border minute is now 1 then +1 + self.assertEqual(office_availability.get_availability_percentage(only_working_hours=False), 37.543372657876475) + + #force remove availability from 9:00 to 12:00 # 3 hour less availability + start_date = datetime.datetime(today.year, today.month, today.day, 9, 00, tzinfo=today.tzinfo).replace(second=0, microsecond=0) + end_date = start_date + datetime.timedelta(hours=3) + office_availability.remove_availability(pd.date_range(start=start_date, end=end_date, freq='1T'), only_working_hours=True) + 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), 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) + + + + diff --git a/smash/web/tests/view/test_appointments.py b/smash/web/tests/view/test_appointments.py index a1544f213bdcfcb70a699f4b9299c61faa5b069d..e17bb743efcab2bf5f7b467e2786592f7ed1c566 100644 --- a/smash/web/tests/view/test_appointments.py +++ b/smash/web/tests/view/test_appointments.py @@ -4,11 +4,11 @@ import logging from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse -from web.forms import SubjectEditForm, StudySubjectEditForm, AppointmentEditForm +from web.forms import SubjectEditForm, StudySubjectEditForm, AppointmentEditForm, AppointmentAddForm from web.models import Appointment, StudySubject, Visit from web.tests import LoggedInTestCase from web.tests.functions import create_study_subject, create_visit, create_appointment, create_worker, \ - create_flying_team, format_form_field + create_flying_team, format_form_field, get_test_location from web.views.notifications import get_today_midnight_date logger = logging.getLogger(__name__) @@ -19,6 +19,41 @@ class AppointmentsViewTests(LoggedInTestCase): super(AppointmentsViewTests, self).setUp() create_worker(self.user, True) + def test_get_add_general_appointment(self): + #test get without visit_id + response = self.client.get(reverse('web.views.appointment_add_general')) + self.assertEqual(response.status_code, 200) + def test_get_add_appointment(self): + #test get with visit_id + subject = create_study_subject() + visit = create_visit(subject) + response = self.client.get(reverse('web.views.appointment_add', + kwargs={'visit_id': visit.id})) + self.assertEqual(response.status_code, 200) + + def test_post_add_general_appointment(self): + location = get_test_location() + form_appointment = AppointmentAddForm(user=self.user) + form_data = {} + form_data['datetime_when'] = datetime.datetime.today() + form_data['location'] = location.id + form_data['length'] = 10 + response = self.client.post(reverse('web.views.appointment_add_general'), data=form_data) + self.assertEqual(response.status_code, 302) + + def test_add_appointment(self): + subject = create_study_subject() + visit = create_visit(subject) + location = get_test_location() + form_data = {} + form_appointment = AppointmentAddForm(user=self.user) + form_data['datetime_when'] = datetime.datetime.today() + form_data['location'] = location.id + form_data['length'] = 10 + response = self.client.post(reverse('web.views.appointment_add', + kwargs={'visit_id': visit.id}), data=form_data) + self.assertEqual(response.status_code, 302) + def test_appointments_list_request(self): response = self.client.get(reverse('web.views.appointments')) self.assertEqual(response.status_code, 200) diff --git a/smash/web/tests/view/test_utils.py b/smash/web/tests/view/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..ba1df2735e2b85d48cc3884560800401b9f07ccf --- /dev/null +++ b/smash/web/tests/view/test_utils.py @@ -0,0 +1,22 @@ +import logging + +from django.test import TestCase + +from web.utils import get_weekdays_in_period + +from datetime import date +import datetime + +logger = logging.getLogger(__name__) + +class Utils(TestCase): + def test_get_weekdays_in_period(self): + fromdate = date(2018,10,9) + todate = date(2018,10,12) + weekdays = get_weekdays_in_period(fromdate, todate) + self.assertEqual(weekdays, {2, 3, 4}) + + todate = datetime.datetime(2018, 10, 12, 00, 00, 00) + fromdate = datetime.datetime(2018, 10, 9, 00, 00, 00) + weekdays = get_weekdays_in_period(fromdate, todate) + self.assertEqual(weekdays, {2, 3, 4}) \ No newline at end of file diff --git a/smash/web/utils.py b/smash/web/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1bbb74ecc18c444298e49ba893ed84ea865e128d --- /dev/null +++ b/smash/web/utils.py @@ -0,0 +1,31 @@ +# coding=utf-8 +from django.utils import timezone +import datetime +from datetime import timedelta + +def get_today_midnight_date(): + today = timezone.now() + today_midnight = datetime.datetime(today.year, today.month, today.day, tzinfo=today.tzinfo) + return today_midnight + +def get_weekdays_in_period(fromdate, todate): + ''' + fromdate and todate must be generated using datetime.date or datetime.datetime like: + + from datetime import date + fromdate = date(2010,1,1) + todate = date(2010,3,31) + + fromdate = datetime.datetime(2018, 10, 3, 15, 00, 00) + todate = datetime.datetime.today() + + but both dates must have the same format ! + todate is not included in the range + + Weekdays are returned as isoweekdays like the form described in week_choices from constants.py (starting at 1) + ''' + if todate < fromdate: + return set([]) + day_generator = (fromdate + timedelta(day) for day in xrange((todate - fromdate).days)) + weekdays = set([date.isoweekday() for date in day_generator]) + return weekdays \ No newline at end of file diff --git a/smash/web/views/appointment.py b/smash/web/views/appointment.py index c4a9ddf1f5a37259e369de156f44b5ea6031d564..bc782e45a686222e32dab5465f3cbff5f158cd2b 100644 --- a/smash/web/views/appointment.py +++ b/smash/web/views/appointment.py @@ -1,7 +1,7 @@ # coding=utf-8 import logging import re - +import datetime from django.contrib import messages from django.core.exceptions import ValidationError from django.shortcuts import get_object_or_404, redirect @@ -11,7 +11,7 @@ from web.models.appointment_list import APPOINTMENT_LIST_APPROACHING, APPOINTMEN from . import wrap_response from web.forms import AppointmentDetailForm, AppointmentEditForm, AppointmentAddForm, SubjectEditForm, \ StudySubjectEditForm -from ..models import Appointment, StudySubject, MailTemplate +from ..models import Appointment, StudySubject, MailTemplate, Visit logger = logging.getLogger(__name__) @@ -40,6 +40,13 @@ def appointment_details(request, id): def appointment_add(request, visit_id=None): + if visit_id is not None: + visit = get_object_or_404(Visit, id=visit_id) + visit_start = visit.datetime_begin.strftime("%Y-%m-%d") + visit_end = visit.datetime_end.strftime("%Y-%m-%d") + else: + visit_start = datetime.datetime.today().strftime("%Y-%m-%d") + visit_end = datetime.datetime.today().strftime("%Y-%m-%d") if request.method == 'POST': form = AppointmentAddForm(request.POST, request.FILES, user=request.user) if form.is_valid(): @@ -49,11 +56,16 @@ def appointment_add(request, visit_id=None): return redirect('web.views.appointments') else: return redirect('web.views.visit_details', id=visit_id) + else: + raise ValidationError("Invalid request: Errors: {}. Non field errors: {}".format(form.errors, form.non_field_errors())) + else: form = AppointmentAddForm(user=request.user) return wrap_response(request, 'appointments/add.html', - {'form': form, 'visitID': visit_id, 'full_list': APPOINTMENT_LIST_GENERIC}) + {'form': form, 'visitID': visit_id, 'isGeneral': visit_id is None, + 'visit_start': visit_start, 'visit_end': visit_end, + 'full_list': APPOINTMENT_LIST_GENERIC}) def appointment_edit(request, id): diff --git a/smash/web/views/daily_planning.py b/smash/web/views/daily_planning.py index 3988cc23ed7125c0415a36c730e2ed4c1d45c7aa..40ab776c754d5bc3c8b89e8d9e1ec70701669f53 100644 --- a/smash/web/views/daily_planning.py +++ b/smash/web/views/daily_planning.py @@ -3,7 +3,10 @@ import logging from django.views.generic import TemplateView from . import wrap_response +from web.models.worker_study_role import STUDY_ROLE_CHOICES + class TemplateDailyPlannerView(TemplateView): def get(self, request, *args, **kwargs): - context = self.get_context_data(**kwargs) + context = self.get_context_data(**kwargs) + context['worker_study_roles'] = STUDY_ROLE_CHOICES return wrap_response(request, 'daily_planning.html', context) \ No newline at end of file diff --git a/smash/web/views/notifications.py b/smash/web/views/notifications.py index edee35b6d89590df8eb4fc67d16d236946c42f6a..20246b9ecd1b4a674e7cf42437c0b63588b2bbb9 100644 --- a/smash/web/views/notifications.py +++ b/smash/web/views/notifications.py @@ -4,8 +4,8 @@ import logging from django.contrib.auth.models import User, AnonymousUser from django.db.models import Count, Case, When, Q, F, Max -from django.utils import timezone +from web.utils import get_today_midnight_date from web.models import Study, Worker, StudySubject, Visit, Appointment, Location, MissingSubject, InconsistentSubject from web.models.constants import GLOBAL_STUDY_ID, VOUCHER_STATUS_NEW @@ -327,7 +327,3 @@ def get_filter_locations(user): return worker.locations.all() -def get_today_midnight_date(): - today = timezone.now() - today_midnight = datetime.datetime(today.year, today.month, today.day, tzinfo=today.tzinfo) - return today_midnight diff --git a/smash/web/views/worker.py b/smash/web/views/worker.py index f3b135734d8325c450e626997898ab440caad0f8..c2ca3cba16c1cfe96f53e26a1e3d22549bd42524 100644 --- a/smash/web/views/worker.py +++ b/smash/web/views/worker.py @@ -6,7 +6,7 @@ from django.shortcuts import redirect, get_object_or_404 from web.forms import AvailabilityAddForm, AvailabilityEditForm, HolidayAddForm from web.forms import WorkerForm from web.models import Worker, Availability, Holiday -from web.models.constants import WEEKDAY_CHOICES, GLOBAL_STUDY_ID +from web.models.constants import WEEKDAY_CHOICES, GLOBAL_STUDY_ID, AVAILABILITY_CHOICES from web.models.worker import worker_type_by_worker from web.models.worker_study_role import WORKER_STAFF from . import wrap_response @@ -55,6 +55,7 @@ def worker_edit(request, worker_id): 'holidays': holidays, 'doctor_id': worker_id, 'weekdays': WEEKDAY_CHOICES, + 'availability_choices': AVAILABILITY_CHOICES, "worker_type": worker_type }) @@ -69,7 +70,7 @@ def worker_availability_delete(request, availability_id): availability = Availability.objects.filter(id=availability_id) doctor_id = availability[0].person.id availability.delete() - return redirect(worker_edit, doctor_id=doctor_id) + return redirect(worker_edit, worker_id=doctor_id) def worker_availability_add(request, doctor_id): @@ -78,13 +79,14 @@ def worker_availability_add(request, doctor_id): form = AvailabilityAddForm(request.POST, request.FILES) if form.is_valid(): form.save() - return redirect(worker_edit, doctor_id=doctor_id) + return redirect(worker_edit, worker_id=doctor_id) else: form = AvailabilityAddForm(initial={'person': worker}) return wrap_response(request, 'doctors/add_availability.html', { 'form': form, - 'doctor_id': doctor_id + 'doctor_id': doctor_id, + 'doctor_name': unicode(worker) }) @@ -94,7 +96,7 @@ def worker_availability_edit(request, availability_id): form = AvailabilityEditForm(request.POST, request.FILES, instance=availability) if form.is_valid(): form.save() - return redirect(worker_edit, doctor_id=availability.person_id) + return redirect(worker_edit, worker_id=availability.person_id) else: form = AvailabilityEditForm(instance=availability) return wrap_response(request, 'doctors/edit_availability.html', @@ -109,7 +111,7 @@ def worker_holiday_delete(request, holiday_id): holiday = Holiday.objects.filter(id=holiday_id) doctor_id = holiday[0].person.id holiday.delete() - return redirect(worker_edit, doctor_id=doctor_id) + return redirect(worker_edit, worker_id=doctor_id) def worker_holiday_add(request, doctor_id): @@ -119,13 +121,15 @@ def worker_holiday_add(request, doctor_id): doctor = doctors[0] if request.method == 'POST': form = HolidayAddForm(request.POST, request.FILES) + if form.is_valid(): form.save() - return redirect(worker_edit, doctor_id=doctor_id) + return redirect(worker_edit, worker_id=doctor_id) else: form = HolidayAddForm(initial={'person': doctor}) return wrap_response(request, 'doctors/add_holiday.html', { 'form': form, - 'doctor_id': doctor_id + 'doctor_id': doctor_id, + 'doctor_name': unicode(doctor) })