# Part of Odoo. See LICENSE file for full copyright and licensing details. import pytz from datetime import date, datetime, timedelta, timezone from freezegun import freeze_time from werkzeug.urls import url_encode, url_join import odoo from odoo.addons.appointment.tests.common import AppointmentCommon from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.base.tests.common import HttpCaseWithUserDemo from odoo.exceptions import ValidationError from odoo.tests import Form, tagged, users from odoo.tools import mute_logger from odoo.fields import Command @tagged('appointment_slots') class AppointmentTest(AppointmentCommon, HttpCaseWithUserDemo): @freeze_time('2023-12-12') @users('apt_manager') def test_appointment_availability_after_utc_conversion(self): """ Check that when an event starts the day before, it doesn't show the date as available for the user. ie: In the brussels TZ, when placing an event on the 15 dec at 00:15 to 18:00, the event is stored in the UTC TZ on the 14 dec at 23:15 to 17:00 Because the event start on another day, the 15th was displayed as available. """ staff_user = self.staff_users[0] week_days = [0, 1, 2] # The user works on mondays, tuesdays, and wednesdays # Only one hour slot per weekday self.apt_type_bxls_2days.slot_ids = [(5, 0)] + [(0, 0, { 'weekday': str(week_day + 1), 'start_hour': 8, 'end_hour': 9, }) for week_day in week_days] # Available on the 12, 13, 18, 19, 20, 25, 26, 27 dec max_available_slots = 8 test_data = [ # Test 1, after UTC # Brussels TZ: 2023-12-19 00:15 to 2023-12-19 18:00 => same day # UTC TZ: 2023-12-18 23:15 to 2023-12-19 17:00 => different day ( datetime(2023, 12, 18, 23, 15), datetime(2023, 12, 19, 17, 0), max_available_slots - 1, {date(2023, 12, 19): []}, ), # Test 2, before UTC # New York TZ: 2023-12-18 10:00 to 2023-12-18 22:00 => same day # UTC TZ: 2023-12-18 15:00 to 2023-12-19 03:00 => different day ( datetime(2023, 12, 18, 15, 0), datetime(2023, 12, 19, 3, 0), max_available_slots - 0, {}, ), ] global_slots_startdate = date(2023, 11, 26) global_slots_enddate = date(2024, 1, 6) slots_startdate = date(2023, 12, 12) slots_enddate = slots_startdate + timedelta(days=15) for start, stop, nb_available_slots, slots_day_specific in test_data: with self.subTest(start=start, stop=stop, nb_available_slots=nb_available_slots): event = self.env["calendar.event"].create([ { "name": "event-1", "start": start, "stop": stop, "show_as": 'busy', "partner_ids": staff_user.partner_id.ids, "attendee_ids": [(0, 0, { "state": "accepted", "availability": "busy", "partner_id": staff_user.partner_id.id, })], }, ]) slots = self.apt_type_bxls_2days._get_appointment_slots(timezone='Europe/Brussels', filter_users=staff_user) self.assertSlots( slots, [{'name_formated': 'December 2023', 'month_date': datetime(2023, 12, 1), 'weeks_count': 6, } ], {'startdate': global_slots_startdate, 'enddate': global_slots_enddate, 'slots_startdate': slots_startdate, 'slots_enddate': slots_enddate, 'slots_start_hours': [8], 'slots_weekdays_nowork': range(3, 7), 'slots_day_specific': slots_day_specific, } ) available_slots = self._filter_appointment_slots(slots, filter_weekdays=week_days) self.assertEqual(nb_available_slots, len(available_slots)) event.unlink() @freeze_time('2023-01-6') @users('apt_manager') def test_appointment_availability_with_show_as(self): """ Checks that if a normal event and custom event both set at the same time but the normal event is set as free then the custom meeting should be available and available_unique_slots will contains only available slots """ employee = self.staff_users[0] self.env["calendar.event"].create([ { "name": "event-1", "start": datetime(2023, 6, 5, 10, 10), "stop": datetime(2023, 6, 5, 11, 11), "show_as": 'free', "partner_ids": [(Command.set(employee.partner_id.ids))], "attendee_ids": [(0, 0, { "state": "accepted", "availability": "free", "partner_id": employee.partner_id.id, })], }, { "name": "event-2", "start": datetime(2023, 6, 5, 12, 0), "stop": datetime(2023, 6, 5, 13, 0), "show_as": 'busy', "partner_ids": [(Command.set(employee.partner_id.ids))], "attendee_ids": [(0, 0, { "state": "accepted", "availability": "busy", "partner_id": employee.partner_id.id, })], }, ]) unique_slots = [{ 'allday': False, 'start_datetime': datetime(2023, 6, 5, 10, 10), 'end_datetime': datetime(2023, 6, 5, 11, 11), }, { 'allday': False, 'start_datetime': datetime(2023, 6, 5, 12, 0), 'end_datetime': datetime(2023, 6, 5, 13, 0), }] hour_fifty_float_repr_A = 1.8333333333333335 hour_fifty_float_repr_B = 1.8333333333333333 apt_types = self.env['appointment.type'].create([ { 'category': 'custom', 'name': 'Custom Meeting 1', 'staff_user_ids': [(4, employee.id)], 'slot_ids': [(0, 0, { 'allday': slot['allday'], 'end_datetime': slot['end_datetime'], 'slot_type': 'unique', 'start_datetime': slot['start_datetime'], }) for slot in unique_slots ], }, { 'category': 'custom', 'name': 'Custom Meeting 2', 'staff_user_ids': [(4, employee.id)], 'slot_ids': [(0, 0, { 'allday': unique_slots[1]['allday'], 'end_datetime': unique_slots[1]['end_datetime'], 'slot_type': 'unique', 'start_datetime': unique_slots[1]['start_datetime'], }) ], }, { 'category': 'recurring', 'name': 'Recurring Meeting 3', 'staff_user_ids': [(4, employee.id)], 'appointment_duration': hour_fifty_float_repr_A, # float presenting 1h 50min 'appointment_tz': 'UTC', 'slot_ids': [ (0, False, { 'weekday': '1', # Monday 'start_hour': 8, 'end_hour': 17, } ) ] }, ]) self.assertTrue( apt_types[-1]._check_appointment_is_valid_slot( employee, 0, 0, 'UTC', datetime(2023, 1, 9, 8, 0, tzinfo=timezone.utc), # First monday in the future duration=hour_fifty_float_repr_B ), "Small imprecision on float value for duration should not impact slot validity" ) slots = apt_types[0]._get_appointment_slots('UTC') available_unique_slots = self._filter_appointment_slots( slots, filter_months=[(6, 2023)], filter_users=employee) self.assertEqual(len(available_unique_slots), 1) for unique_slot, apt_type, is_available in zip(unique_slots, apt_types, [True, False]): duration = (unique_slot['end_datetime'] - unique_slot['start_datetime']).total_seconds() / 3600 self.assertEqual( apt_type._check_appointment_is_valid_slot( employee, 0, 0, 'UTC', unique_slot['start_datetime'], duration ), is_available ) self.assertEqual( employee.partner_id.calendar_verify_availability( unique_slot['start_datetime'], unique_slot['end_datetime'], ), is_available ) @users('apt_manager') def test_appointment_type_create_anytime(self): # Any Time: only 1 / employee apt_type = self.env['appointment.type'].create({ 'category': 'anytime', 'name': 'Any time on me', }) self.assertEqual(apt_type.staff_user_ids, self.apt_manager) # should be able to create 2 'anytime' appointment types at once on different users self.env['appointment.type'].create([{ 'category': 'anytime', 'name': 'Any on staff user', 'staff_user_ids': [(4, staff_user.id)], } for staff_user in self.staff_users]) with self.assertRaises(ValidationError): self.env['appointment.type'].create({ 'category': 'anytime', 'name': 'Any time on me, duplicate', }) with self.assertRaises(ValidationError): self.env['appointment.type'].create({ 'name': 'Any time without employees', 'category': 'anytime', 'staff_user_ids': False }) with self.assertRaises(ValidationError): self.env['appointment.type'].create({ 'name': 'Any time with multiple employees', 'category': 'anytime', 'staff_user_ids': [(6, 0, self.staff_users.ids)] }) @users('apt_manager') def test_appointment_type_create_custom(self): # Custom: current user set as default apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'name': 'Custom without user', }) self.assertEqual(apt_type.staff_user_ids, self.apt_manager) apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'staff_user_ids': [(4, self.staff_users[0].id)], 'name': 'Custom with user', }) self.assertEqual(apt_type.staff_user_ids, self.staff_users[0]) apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'staff_user_ids': self.staff_users.ids, 'name': 'Custom with users', }) self.assertEqual(apt_type.staff_user_ids, self.staff_users) @mute_logger('odoo.sql_db') @users('apt_manager') def test_appointment_slot_start_end_hour_auto_correction(self): """ Test the autocorrection of invalid intervals [start_hour, end_hour]. """ appt_type = self.env['appointment.type'].create({ 'category': 'recurring', 'name': 'Schedule a demo', 'appointment_duration': 1, 'slot_ids': [(0, 0, { 'weekday': '1', # Monday 'start_hour': 9, 'end_hour': 17, })], }) appt_form = Form(appt_type) # invalid interval, no adaptation because start_hour is not changed with self.assertRaises(ValidationError): with appt_form.slot_ids.edit(0) as slot_form: slot_form.end_hour = 8 appt_form.save() # invalid interval, adapted because start_hour is changed with appt_form.slot_ids.edit(0) as slot_form: slot_form.start_hour = 18 self.assertEqual(slot_form.start_hour, 18) self.assertEqual(slot_form.end_hour, 19) appt_form.save() # empty interval, adapted because start_hour is changed with appt_form.slot_ids.edit(0) as slot_form: slot_form.start_hour = 19 self.assertEqual(slot_form.start_hour, 19) self.assertEqual(slot_form.end_hour, 20) appt_form.save() # invalid interval, end_hour not adapted [23.5, 19] because it will exceed 24 with self.assertRaises(ValidationError): with appt_form.slot_ids.edit(0) as slot_form: slot_form.start_hour = 23.5 appt_form.save() def test_generate_slots_until_midnight(self): """ Generate recurring slots until midnight. """ appt_type = self.env['appointment.type'].create({ 'category': 'recurring', 'name': 'Schedule a demo', 'max_schedule_days': 1, 'appointment_duration': 1, 'appointment_tz': 'Europe/Brussels', 'slot_ids': [(0, 0, { 'weekday': '1', # Monday 'start_hour': 18, 'end_hour': 0, })], 'staff_user_ids': [(4, self.staff_user_bxls.id)], }).with_user(self.env.user) with freeze_time(self.reference_now): slots = appt_type._get_appointment_slots('Europe/Brussels') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_start_hours': [18, 19, 20, 21, 22, 23], 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_enddate': self.reference_monday.date(), # only test that day } ) @users('apt_manager') def test_appointment_type_custom_badge(self): """ Check that the number of previous and next slots in the badge are correctly based on availability """ reference_start = self.reference_monday.replace(microsecond=0) unique_slots = [{ 'allday': True, 'end_datetime': reference_start + timedelta(days=delta_day + 1), 'slot_type': 'unique', 'start_datetime': reference_start + timedelta(days=delta_day), } for delta_day in (0, 1, 31, 62, 63)] apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'name': 'Custom Appointment Type', 'slot_ids': [(5, 0)] + [(0, 0, slot) for slot in unique_slots], }) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('UTC') nb_february_slots = len(self._filter_appointment_slots( slots, filter_months=[(2, 2022)], filter_users=self.apt_manager)) nb_march_slots = len(self._filter_appointment_slots( slots, filter_months=[(3, 2022)], filter_users=self.apt_manager)) nb_april_slots = len(self._filter_appointment_slots( slots, filter_months=[(4, 2022)], filter_users=self.apt_manager)) # February month self.assertEqual(slots[0]['nb_slots_previous_months'], 0) self.assertEqual(slots[0]['nb_slots_next_months'], nb_march_slots + nb_april_slots) # March month self.assertEqual(slots[1]['nb_slots_previous_months'], nb_february_slots) self.assertEqual(slots[1]['nb_slots_next_months'], nb_april_slots) # April month self.assertEqual(slots[2]['nb_slots_previous_months'], nb_february_slots + nb_march_slots) self.assertEqual(slots[2]['nb_slots_next_months'], 0) # Create a meeting during the duration of the first slot self._create_meetings(self.apt_manager, [( reference_start + timedelta(hours=2), reference_start + timedelta(hours=3), False, )]) previous_nb_feb_slots = nb_february_slots with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('UTC') nb_february_slots = len(self._filter_appointment_slots( slots, filter_months=[(2, 2022)], filter_users=self.apt_manager)) nb_march_slots = len(self._filter_appointment_slots( slots, filter_months=[(3, 2022)], filter_users=self.apt_manager)) nb_april_slots = len(self._filter_appointment_slots( slots, filter_months=[(4, 2022)], filter_users=self.apt_manager)) # February month self.assertEqual(slots[0]['nb_slots_previous_months'], 0) self.assertEqual(slots[0]['nb_slots_next_months'], nb_march_slots + nb_april_slots) self.assertEqual(nb_february_slots, previous_nb_feb_slots - 1) # March month self.assertEqual(slots[1]['nb_slots_previous_months'], nb_february_slots) self.assertEqual(slots[1]['nb_slots_next_months'], nb_april_slots) # April month self.assertEqual(slots[2]['nb_slots_previous_months'], nb_february_slots + nb_march_slots) self.assertEqual(slots[2]['nb_slots_next_months'], 0) @freeze_time('2023-01-9') def test_booking_validity(self): """ When confirming an appointment, we must recheck that it is indeed a valid slot, because the user can modify the date URL parameter used to book the appointment. We make sure the date is a valid slot, not outside of those specified by the employee, and that it's not an old valid slot (a slot that is valid, but it's in the past, so we shouldn't be able to book for a date that has already passed) """ # add the timezone of the visitor on the session (same as appointment to simplify) session = self.authenticate(None, None) session['timezone'] = self.apt_type_bxls_2days.appointment_tz odoo.http.root.session_store.save(session) appointment = self.apt_type_bxls_2days appointment_invite = self.env['appointment.invite'].create({'appointment_type_ids': appointment.ids}) appointment_url = url_join(appointment.get_base_url(), '/appointment/%s' % appointment.id) appointment_info_url = "%s/info?" % appointment_url url_inside_of_slot = appointment_info_url + url_encode({ 'staff_user_id': self.staff_user_bxls.id, 'date_time': datetime(2023, 1, 9, 9, 0), # 9/01/2023 is a Monday, there is a slot at 9:00 'duration': 1, **appointment_invite._get_redirect_url_parameters(), }) response = self.url_open(url_inside_of_slot) self.assertEqual(response.status_code, 200, "Response should be Ok (200)") url_outside_of_slot = appointment_info_url + url_encode({ 'staff_user_id': self.staff_user_bxls.id, 'date_time': datetime(2023, 1, 9, 22, 0), # 9/01/2023 is a Monday, there is no slot at 22:00 'duration': 1, **appointment_invite._get_redirect_url_parameters(), }) response = self.url_open(url_outside_of_slot) self.assertEqual(response.status_code, 404, "Response should be Page Not Found (404)") url_inactive_past_slot = appointment_info_url + url_encode({ 'staff_user_id': self.staff_user_bxls.id, 'date_time': datetime(2023, 1, 2, 22, 0), # 2/01/2023 is a Monday, there is a slot at 9:00, but that Monday has already passed 'duration': 1, **appointment_invite._get_redirect_url_parameters(), }) response = self.url_open(url_inactive_past_slot) self.assertEqual(response.status_code, 404, "Response should be Page Not Found (404)") @freeze_time('2023-04-23') def test_booking_validity_timezone(self): """ When the utc offset of the timezone is large, it is possible that the day of the week no longer corresponds. It is necessary to take this into account when checking the slots. """ appointment = self.env['appointment.type'].create({ 'appointment_tz': 'Pacific/Auckland', 'appointment_duration': 1, 'assign_method': 'time_auto_assign', 'category': 'recurring', 'location_id': self.staff_user_nz.partner_id.id, 'name': 'New Zealand Appointment', 'max_schedule_days': 15, 'min_cancellation_hours': 1, 'min_schedule_hours': 1, 'slot_ids': [ (0, False, {'weekday': weekday, 'start_hour': hour, 'end_hour': hour + 1, }) for weekday in ['1'] for hour in range(9, 12) ], 'staff_user_ids': [(4, self.staff_user_nz.id)], }) session = self.authenticate(None, None) session['timezone'] = appointment.appointment_tz odoo.http.root.session_store.save(session) appointment_invite = self.env['appointment.invite'].create({'appointment_type_ids': appointment.ids}) appointment_url = url_join(appointment.get_base_url(), '/appointment/%s' % appointment.id) appointment_info_url = "%s/info?" % appointment_url url = appointment_info_url + url_encode({ 'staff_user_id': self.staff_user_nz.id, 'date_time': datetime(2023, 4, 24, 9, 0), 'duration': 1, **appointment_invite._get_redirect_url_parameters(), }) response = self.url_open(url) self.assertEqual(response.status_code, 200, "Response should be Ok (200)") def test_exclude_all_day_events(self): """ Ensure appointment slots don't overlap with "busy" allday events. """ staff_user = self.staff_users[0] valentime = datetime(2022, 2, 14, 0, 0) # 2022-02-14 is a Monday slots = self.apt_type_bxls_2days._get_appointment_slots( self.apt_type_bxls_2days.appointment_tz, reference_date=valentime, ) slot = slots[0]['weeks'][2][1] self.assertEqual(slot['day'], valentime.date()) self.assertTrue(slot['slots'], "Should be available on 2022-02-14") self.env['calendar.event'].with_user(staff_user).create({ 'name': "Valentine's day", 'start': valentime, 'stop': valentime, 'allday': True, 'show_as': 'busy', 'attendee_ids': [(0, 0, { 'state': 'accepted', 'availability': 'busy', 'partner_id': staff_user.partner_id.id, })], }) slots = self.apt_type_bxls_2days._get_appointment_slots( self.apt_type_bxls_2days.appointment_tz, reference_date=valentime, ) slot = slots[0]['weeks'][2][1] self.assertEqual(slot['day'], valentime.date()) self.assertFalse(slot['slots'], "Shouldn't be available on 2022-02-14") @users('apt_manager') def test_customer_event_description(self): """Check calendar file description and summary generation.""" appointment_type = self.apt_type_bxls_2days host_user = self.apt_manager host_partner = host_user.partner_id message_confirmation = '

Please try to be there 5 minutes before the time.


Thank you.' host_name = 'Appointment Manager' host_mail = 'manager@appointments.lan' host_phone = '2519475531' def _set_values(message=message_confirmation, name=host_name, mail=host_mail, phone=host_phone): appointment_type.message_confirmation = message host_partner.write({ 'name': name, 'email': mail, 'phone': phone, }) attendee = self.env['res.partner'].sudo().create({ 'name': 'John Doe', }) appointment = self.env['calendar.event'].create({ 'name': '%s with %s' % (appointment_type.name, attendee.name), 'start': datetime.now(), 'start_date': datetime.now(), 'stop': datetime.now() + timedelta(hours=1), 'allday': False, 'duration': appointment_type.appointment_duration, 'description': "

Test

", 'location': appointment_type.location, 'partner_ids': [odoo.Command.link(partner.id) for partner in [attendee, host_partner]], 'appointment_type_id': appointment_type.id, 'user_id': host_user.id, }) # sanity check with a simple test _set_values() self.assertEqual(appointment._get_customer_description(), 'Please try to be there *5 minutes* before the time.\nThank you.\n\n' 'Contact Details:\n' 'Appointment Manager\nEmail: manager@appointments.lan\nPhone: 2519475531') for changes in [{}, {'message': False}, {'name': ''}, {'mail': False}, {'phone': False}, {'name': '', 'mail': False, 'phone': False}, {'message': False, 'name': '', 'mail': False, 'phone': False}]: _set_values(**changes) message = '' details = '' if appointment_type.message_confirmation: message = 'Please try to be there *5 minutes* before the time.\nThank you.\n\n' if host_partner.name or host_partner.email or host_partner.phone: details = '\n'.join(line for line in ( 'Contact Details:', host_partner.name, f'Email: {host_partner.email}' if host_partner.email else False, f'Phone: {host_partner.phone}' if host_partner.phone else False) if line) self.assertEqual(appointment._get_customer_description(), (message + details).strip()) # Test summary for all appointment types resource_appointment = self.apt_type_resource resource_event = self.env['calendar.event'].create({ 'name': '%s - %s' % (resource_appointment.name, attendee.name), 'start': datetime.now(), 'stop': datetime.now() + timedelta(hours=1), 'appointment_type_id': resource_appointment.id, 'user_id': host_user.id, }) user_summary = f'{appointment_type.name} with {host_partner.name or "somebody"}' for event, summary in ((appointment, user_summary), (resource_event, resource_event.name)): with self.subTest(summary=summary): self.assertEqual(event._get_customer_summary(), summary) @users('apt_manager') @freeze_time('2022-02-13T20:00:00') def test_generate_slots_punctual_appointment_type(self): """ Generates recurring slots, check begin and end slot boundaries depending on the start and end datetimes. """ apt_type = self.env['appointment.type'].create({ 'appointment_tz': 'Europe/Brussels', 'appointment_duration': 1, 'assign_method': 'time_auto_assign', 'category': 'punctual', 'location_id': self.staff_user_bxls.partner_id.id, 'name': 'Punctual Appt Type', 'max_schedule_days': False, 'min_cancellation_hours': 1, 'min_schedule_hours': 1, 'start_datetime': datetime(2022, 2, 14, 8, 0, 0), 'end_datetime': datetime(2022, 2, 20, 20, 0, 0), 'slot_ids': [ (0, False, {'weekday': weekday, 'start_hour': hour, 'end_hour': hour + 1, }) for weekday in ['1', '2'] for hour in range(8, 14) ], 'staff_user_ids': [(4, self.staff_user_bxls.id)], }).with_user(self.env.user) slots_weekdays = {slot.weekday for slot in apt_type.slot_ids} timezone = 'Europe/Brussels' requested_tz = pytz.timezone(timezone) # reference_now: datetime(2022, 2, 13, 20, 0, 0) (sunday evening) # apt slot_ids: Monday 8AM -> 2PM, Tuesday 8AM -> 2PM cases = [ # start datetime / end_datetime (UTC) (datetime(2022, 2, 14, 9, 0, 0), datetime(2022, 2, 25, 9, 0, 0)), # Slots fully in the future (datetime(2022, 2, 1, 9, 0, 0), datetime(2022, 2, 25, 9, 0, 0)), # start_datetime < now < end_datetimes (datetime(2022, 2, 1, 9, 0, 0), datetime(2022, 2, 12, 9, 0, 0)), # Slots fully in the past ] expected = [ # first slot start datetime / last slot end_datetime (UTC) (datetime(2022, 2, 14, 9, 0, 0), datetime(2022, 2, 22, 13, 0, 0)), # start = specified start_datetime (datetime(2022, 2, 14, 7, 0, 0), datetime(2022, 2, 22, 13, 0, 0)), # start = 8AM from apt slots_ids converted to UTC (False, False) ] for (start_datetime, end_datetime), (first_slot_expected_start, last_slot_expected_end) in zip(cases, expected): with self.subTest(start_datetime=start_datetime, end_datetime=end_datetime): apt_type.write({'start_datetime': start_datetime, 'end_datetime': end_datetime}) reference_date = start_datetime if start_datetime > self.reference_now else self.reference_now first_day = requested_tz.fromutc(reference_date) last_day = requested_tz.fromutc(end_datetime) slots = apt_type._slots_generate(first_day, last_day, timezone, reference_date=reference_date) if not slots: self.assertFalse(first_slot_expected_start) self.assertFalse(last_slot_expected_end) continue self.assertTrue({slot['slot'].weekday for slot in slots}.issubset(slots_weekdays), 'Slots: wrong weekday') self.assertEqual(slots[0]['UTC'][0], first_slot_expected_start, 'Slots: wrong first slot start datetime') self.assertEqual(slots[-1]['UTC'][1], last_slot_expected_end, 'Slots: wrong last slot end datetime') @users('apt_manager') def test_generate_slots_recurring(self): """ Generates recurring slots, check begin and end slot boundaries. """ apt_type = self.apt_type_bxls_2days.with_user(self.env.user) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('Europe/Brussels') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_start_hours': [8, 9, 10, 11, 12, 13], # based on appointment type start hours of slots, no work hours / no meetings / no leaves 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) @users('apt_manager') def test_generate_slots_recurring_start_hour_day_overflow(self): """ Generates recurring slots, make sure we don't overshoot the current day and generate meaningless slots """ slots = [{ 'weekday': '1', 'start_hour': 9.0, 'end_hour': 10.0, }, { 'weekday': '1', 'start_hour': 10.0, 'end_hour': 11.0, }, { 'weekday': '1', 'start_hour': 15.0, 'end_hour': 16.0, }, { 'weekday': '2', 'start_hour': 9.0, 'end_hour': 17.0, },] apt_type = self.env['appointment.type'].create({ 'appointment_duration': 1.0, 'appointment_tz': 'Europe/Brussels', 'category': 'recurring', 'name': 'Overflow Appointment', 'max_schedule_days': 8, 'min_schedule_hours': 12.0, 'slot_ids': [(0, 0, slot) for slot in slots], 'staff_user_ids': [self.env.user.id], }) # Check around the 11AM(Brussels) mark, or 15:30PM(Kolkata) # If we add 12 for the minimum schedule hour it's past 11PM # Past 11 the appointment duration will put us past the current day brussels_tz = pytz.timezone('Europe/Brussels') for hour, minute in [[h, m] for h in [2, 9, 10, 11, 12] for m in [0, 1, 59]]: time = brussels_tz.localize(self.reference_monday.replace(hour=hour, minute=minute)) with freeze_time(time): slots = apt_type._get_appointment_slots('Asia/Kolkata') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': date(2022, 3, 5), 'startdate': self.reference_now_monthweekstart, 'slots_day_specific': { # +4 instead of +4.5 because the test method only accounts for the absolute hour time.date(): [{'start': 15 + 4, 'end': 16 + 4}] if hour == 2 else [], # min_schedule_hours is too large (time + timedelta(days=1)).date(): [{'start': start + 4, 'end': start + 5} for start in range(9, 17)], (time + timedelta(days=7)).date(): [{'start': start + 4, 'end': start + 5} for start in range(9, 11)] + [{'start': 15 + 4, 'end': 16 + 4}], (time + timedelta(days=8)).date(): [{'start': start + 4, 'end': start + 5} for start in range(9, 17)], }, 'slots_start_hours': [], 'slots_startdate': time.date(), 'slots_weekdays_nowork': range(2, 7) }, ) @users('apt_manager') def test_generate_slots_recurring_UTC(self): """ Generates recurring slots, check begin and end slot boundaries. Force UTC results event if everything is Europe/Brussels based. """ apt_type = self.apt_type_bxls_2days.with_user(self.env.user) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('UTC') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_start_hours': [7, 8, 9, 10, 11, 12], # based on appointment type start hours of slots, no work hours / no meetings / no leaves 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) @users('admin') def test_generate_slots_recurring_westrict(self): """ Generates recurring slots, check user restrictions """ apt_type = self.apt_type_bxls_2days.with_user(self.env.user) # add second staff user and split days based on the two people apt_type.write({'staff_user_ids': [(4, self.staff_user_aust.id)]}) apt_type.slot_ids.filtered(lambda slot: slot.weekday == '1').write({ 'restrict_to_user_ids': [(4, self.staff_user_bxls.id)], }) apt_type.slot_ids.filtered(lambda slot: slot.weekday != '1').write({ 'restrict_to_user_ids': [(4, self.staff_user_aust.id)], }) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('Europe/Brussels') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_start_hours': [8, 9, 10, 11, 12, 13], # based on appointment type start hours of slots, no work hours / no meetings / no leaves 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) # check staff_user_id monday_slots = [ slot for month in slots for week in month['weeks'] for day in week for slot in day['slots'] if day['day'].weekday() == 0 ] tuesday_slots = [ slot for month in slots for week in month['weeks'] for day in week for slot in day['slots'] if day['day'].weekday() == 1 ] self.assertEqual(len(monday_slots), 18, 'Slots: 3 mondays of 6 slots') self.assertTrue(all(slot['staff_user_id'] == self.staff_user_bxls.id for slot in monday_slots)) self.assertEqual(len(tuesday_slots), 12, 'Slots: 2 tuesdays of 6 slots (3rd tuesday is out of range') self.assertTrue(all(slot['staff_user_id'] == self.staff_user_aust.id for slot in tuesday_slots)) @users('apt_manager') def test_generate_slots_recurring_wmeetings(self): """ Generates recurring slots, check begin and end slot boundaries with leaves involved. """ apt_type = self.apt_type_bxls_2days.with_user(self.env.user) # create meetings _meetings = self._create_meetings( self.staff_user_bxls, [(self.reference_monday + timedelta(days=1), # 3 hours first Tuesday self.reference_monday + timedelta(days=1, hours=3), False ), (self.reference_monday + timedelta(days=7), # next Monday: one full day self.reference_monday + timedelta(days=7, hours=1), True, ), (self.reference_monday + timedelta(days=8, hours=2), # 1 hour next Tuesday (9 UTC) self.reference_monday + timedelta(days=8, hours=3), False, ), (self.reference_monday + timedelta(days=8, hours=3), # 1 hour next Tuesday (10 UTC, declined) self.reference_monday + timedelta(days=8, hours=4), False, ), (self.reference_monday + timedelta(days=8, hours=5), # 2 hours next Tuesday (12 UTC) self.reference_monday + timedelta(days=8, hours=7), False, ), ] ) attendee = _meetings[-2].attendee_ids.filtered(lambda att: att.partner_id == self.staff_user_bxls.partner_id) attendee.do_decline() with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('Europe/Brussels') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_day_specific': { (self.reference_monday + timedelta(days=1)).date(): [ {'end': 12, 'start': 11}, {'end': 13, 'start': 12}, {'end': 14, 'start': 13}, ], # meetings on 7-10 UTC (self.reference_monday + timedelta(days=7)).date(): [], # on meeting "allday" (self.reference_monday + timedelta(days=8)).date(): [ {'end': 9, 'start': 8}, {'end': 10, 'start': 9}, {'end': 12, 'start': 11}, {'end': 13, 'start': 12}, ], # meetings 9-10 and 12-14 }, 'slots_start_hours': [8, 9, 10, 11, 12, 13], # based on appointment type start hours of slots, no work hours / no meetings / no leaves 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) @users('apt_manager') def test_generate_slots_unique(self): """ Check unique slots (note: custom appointment type does not check working hours). """ unique_slots = [{ 'start_datetime': self.reference_monday.replace(microsecond=0), 'end_datetime': (self.reference_monday + timedelta(hours=1)).replace(microsecond=0), 'allday': False, }, { 'start_datetime': (self.reference_monday + timedelta(days=1)).replace(microsecond=0), 'end_datetime': (self.reference_monday + timedelta(days=2)).replace(microsecond=0), 'allday': True, }] apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'name': 'Custom with unique slots', 'slot_ids': [(5, 0)] + [ (0, 0, {'allday': slot['allday'], 'end_datetime': slot['end_datetime'], 'slot_type': 'unique', 'start_datetime': slot['start_datetime'], } ) for slot in unique_slots ], }) self.assertEqual(apt_type.category, 'custom', "It should be a custom appointment type") self.assertEqual(apt_type.staff_user_ids, self.apt_manager) self.assertEqual(len(apt_type.slot_ids), 2, "Two slots should have been assigned to the appointment type") with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('Europe/Brussels') self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_day_specific': { self.reference_monday.date(): [{'end': 9, 'start': 8}], # first unique 1 hour long (self.reference_monday + timedelta(days=1)).date(): [{'allday': True, 'end': False, 'start': 8}], # second unique all day-based }, 'slots_start_hours': [], # all slots in this tests are unique, other dates have no slots 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) @users('apt_manager') def test_multi_user_slot_availabilities(self): """ Check that when called with no user / one user / several users, the methods computing the slots work as expected: if no user is set, all users of the appointment_type will be used. If one or more users are set, they will be used to compute availabilities. If users given as argument is not among the staff of the appointment type, return empty list. This test only concern random appointments: if it were 'chosen' assignment, then the dropdown of user selection would be in the view. Hence, in practice, only one user would be used to generate / update the slots : the one selected. For random ones, the users can be multiple if a filter is set, assigning randomly among several users. This tests asserts that _get_appointment_slots returns slots properly when called with several users too. If no filter, then the update method would be called with staff_users = False (since select not in view, getting the input value returns false) """ reference_monday = self.reference_monday.replace(microsecond=0) reccuring_slots_utc = [{ 'weekday': '1', 'start_hour': 6.0, # 1 slot : Monday 06:00 -> 07:00 'end_hour': 7.0, }, { 'weekday': '2', 'start_hour': 9.0, # 2 slots : Tuesday 09:00 -> 11:00 'end_hour': 11.0, }] staff_user_no_tz = mail_new_test_user( self.env(su=True), company_id=self.company_admin.id, email='no_tz@test.example.com', groups='base.group_user', name='Employee Without Tz', notification_type='email', login='staff_user_no_tz', tz=False, ) apt_type_UTC = self.env['appointment.type'].create({ 'appointment_tz': 'UTC', 'assign_method': 'time_auto_assign', 'category': 'recurring', 'max_schedule_days': 5, # Only consider the first three slots 'name': 'Private Guitar Lesson', 'slot_ids': [(0, False, { 'weekday': slot['weekday'], 'start_hour': slot['start_hour'], 'end_hour': slot['end_hour'], }) for slot in reccuring_slots_utc], 'staff_user_ids': [self.staff_user_aust.id, self.staff_user_bxls.id, staff_user_no_tz.id], }) exterior_staff_user = self.apt_manager # staff_user_bxls is only available on Wed and staff_user_aust only on Mon and Tue self._create_meetings( self.staff_user_bxls, [(reference_monday - timedelta(hours=1), # Monday 06:00 -> 07:00 reference_monday, False )] ) self._create_meetings( self.staff_user_aust, [(reference_monday + timedelta(days=1, hours=2), # Tuesday 09:00 -> 11:00 reference_monday + timedelta(days=1, hours=4), False )] ) # staff_user_no_tz is only available on Tue between 10 and 11 AM self._create_meetings( staff_user_no_tz, [( self.reference_monday, self.reference_monday.replace(hour=9), True ), ( self.reference_monday + timedelta(days=1, hours=2), self.reference_monday + timedelta(days=1, hours=3), False )]) with freeze_time(self.reference_now): slots_no_user = apt_type_UTC._get_appointment_slots('UTC') slots_exterior_user = apt_type_UTC._get_appointment_slots('UTC', exterior_staff_user) slots_user_aust = apt_type_UTC._get_appointment_slots('UTC', self.staff_user_aust) slots_user_all = apt_type_UTC._get_appointment_slots('UTC', self.staff_user_bxls | self.staff_user_aust) slots_user_bxls_exterior_user = apt_type_UTC._get_appointment_slots('UTC', self.staff_user_bxls | exterior_staff_user) slots_user_no_tz = apt_type_UTC._get_appointment_slots('UTC', staff_user_no_tz) self.assertTrue(len(self._filter_appointment_slots(slots_no_user)) == 3) self.assertFalse(slots_exterior_user) self.assertTrue(len(self._filter_appointment_slots(slots_user_aust)) == 1) self.assertTrue(len(self._filter_appointment_slots(slots_user_all)) == 3) self.assertTrue(len(self._filter_appointment_slots(slots_user_bxls_exterior_user)) == 2) self.assertTrue(len(self._filter_appointment_slots(slots_user_no_tz)) == 1) @users('apt_manager') def test_slots_for_today(self): test_reference_now = datetime(2022, 2, 14, 11, 0, 0) # is a Monday appointment = self.env['appointment.type'].create({ 'appointment_tz': 'UTC', 'min_schedule_hours': 1.0, 'max_schedule_days': 8, 'name': 'Test', 'slot_ids': [(0, 0, { 'weekday': str(test_reference_now.isoweekday()), 'start_hour': 6, 'end_hour': 18, })], 'staff_user_ids': [self.staff_user_bxls.id], }) first_day = (test_reference_now + timedelta(hours=appointment.min_schedule_hours)).astimezone(pytz.UTC) last_day = (test_reference_now + timedelta(days=appointment.max_schedule_days)).astimezone(pytz.UTC) with freeze_time(test_reference_now): slots = appointment._slots_generate(first_day, last_day, 'UTC') self.assertEqual(len(slots), 18, '2 mondays of 12 slots but 6 would be before reference date') for slot in slots: self.assertTrue( test_reference_now.astimezone(pytz.UTC) < slot['UTC'][0].astimezone(pytz.UTC), "A slot shouldn't be generated before the first_day datetime") @users('apt_manager') def test_slots_days_min_schedule(self): """ Test that slots are generated correctly when min_schedule_hours is 47.0. This means that the first returned slots should be on wednesday at 11:36. """ test_reference_now = datetime(2022, 2, 14, 11, 45, 0) # is a Monday appointment = self.env['appointment.type'].create({ 'appointment_tz': 'UTC', 'appointment_duration': 1.2, # 1h12 'min_schedule_hours': 47.0, 'max_schedule_days': 8, 'name': 'Test', 'slot_ids': [ (0, False, {'weekday': weekday, 'start_hour': 8, 'end_hour': 14, }) for weekday in map(str, range(1, 4)) ], 'staff_user_ids': [self.staff_user_bxls.id], }) first_day = (test_reference_now + timedelta(hours=appointment.min_schedule_hours)).astimezone(pytz.UTC) last_day = (test_reference_now + timedelta(days=appointment.max_schedule_days)).astimezone(pytz.UTC) with freeze_time(test_reference_now): slots = appointment._slots_generate(first_day, last_day, 'UTC') for slot in slots: self.assertTrue( first_day < slot['UTC'][0].astimezone(pytz.UTC), "A slot shouldn't be generated before the first_day datetime") self.assertEqual(len(slots), 12) # 2 days of 5 slots and 2 slots on wednesday @users('apt_manager') def test_slots_days_min_schedule_punctual(self): """ Test that slots are generated correctly when min_schedule_hours is 47.0 for punctual appointment. This means that the first returned slots should be on wednesday at 11:36. """ test_reference_now = datetime(2022, 2, 14, 11, 45, 0) # is a Monday appointment = self.env['appointment.type'].create({ 'appointment_tz': 'UTC', 'appointment_duration': 1.2, # 1h12 'category': 'punctual', 'min_schedule_hours': 47.0, 'max_schedule_days': False, 'name': 'Test', 'slot_ids': [ (0, False, {'weekday': weekday, 'start_hour': 8, 'end_hour': 14, }) for weekday in ['1', '2', '3', '4', '5'] ], 'start_datetime': datetime(2022, 2, 15, 9, 0, 0), 'end_datetime': datetime(2022, 2, 25, 9, 0, 0), 'staff_user_ids': [self.staff_user_bxls.id], }) with freeze_time(test_reference_now): slots = appointment.sudo()._get_appointment_slots('UTC') slots = self._filter_appointment_slots(slots) self.assertEqual(slots[0]['datetime'], "2022-02-16 11:36:00", "The first slot should take into account the min schedule hours") self.assertEqual(slots[-1]['datetime'], "2022-02-24 12:48:00") @users('staff_user_aust') def test_timezone_delta(self): """ Test timezone delta. Not sure what original test was really doing. """ # As if the second user called the function apt_type = self.apt_type_bxls_2days.with_user(self.env.user).with_context( lang='en_US', tz=self.staff_user_aust.tz, uid=self.staff_user_aust.id, ) # Do what the controller actually does, aka sudo with freeze_time(self.reference_now): slots = apt_type.sudo()._get_appointment_slots('Australia/Perth', filter_users=None) global_slots_enddate = date(2022, 4, 2) # last day of last week of March self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) }, {'name_formated': 'March 2022', 'month_date': datetime(2022, 3, 1), 'weeks_count': 5, # 28/02 -> 28/03 (03/04) } ], {'enddate': global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_enddate': self.reference_now.date() + timedelta(days=15), # maximum 2 weeks of slots 'slots_start_hours': [15, 16, 17, 18, 19, 20], # based on appointment type start hours of slots, no work hours / no meetings / no leaves, set in UTC+8 'slots_startdate': self.reference_monday.date(), # first Monday after reference_now 'slots_weekdays_nowork': range(2, 7) # working hours only on Monday/Tuesday (0, 1) } ) @users('apt_manager') def test_unique_slots_availabilities(self): """ Check that the availability of each unique slot is correct. First we test that the 2 unique slots of the custom appointment type are available. Then we check that there is now only 1 availability left after the creation of a meeting which encompasses a slot. """ reference_monday = self.reference_monday.replace(microsecond=0) unique_slots = [{ 'allday': False, 'end_datetime': reference_monday + timedelta(hours=1), 'start_datetime': reference_monday, }, { 'allday': False, 'end_datetime': reference_monday + timedelta(hours=3), 'start_datetime': reference_monday + timedelta(hours=2), }] apt_type = self.env['appointment.type'].create({ 'category': 'custom', 'name': 'Custom with unique slots', 'slot_ids': [(0, 0, { 'allday': slot['allday'], 'end_datetime': slot['end_datetime'], 'slot_type': 'unique', 'start_datetime': slot['start_datetime'], }) for slot in unique_slots ], }) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('UTC') # get all monday slots where apt_manager is available available_unique_slots = self._filter_appointment_slots( slots, filter_months=[(2, 2022)], filter_weekdays=[0], filter_users=self.apt_manager) self.assertEqual(len(available_unique_slots), 2) # Create a meeting encompassing the first unique slot self._create_meetings(self.apt_manager, [( unique_slots[0]['start_datetime'], unique_slots[0]['end_datetime'], False, )]) with freeze_time(self.reference_now): slots = apt_type._get_appointment_slots('UTC') available_unique_slots = self._filter_appointment_slots( slots, filter_months=[(2, 2022)], filter_weekdays=[0], filter_users=self.apt_manager) self.assertEqual(len(available_unique_slots), 1) self.assertEqual( available_unique_slots[0]['datetime'], unique_slots[1]['start_datetime'].strftime('%Y-%m-%d %H:%M:%S'), ) def test_check_appointment_timezone(self): session = self.authenticate(None, None) odoo.http.root.session_store.save(session) appointment = self.apt_type_bxls_2days appointment_invite = self.env['appointment.invite'].create({'appointment_type_ids': appointment.ids}) appointment_url = url_join(appointment.get_base_url(), '/appointment/%s' % appointment.id) appointment_info_url = "%s/info?" % appointment_url url_inside_of_slot = appointment_info_url + url_encode({ 'staff_user_id': self.staff_user_bxls.id, 'date_time': datetime(2023, 1, 9, 9, 0), # 9/01/2023 is a Monday, there is a slot at 9:00 'duration': 1, **appointment_invite._get_redirect_url_parameters(), }) # User should be able open url without timezone session self.url_open(url_inside_of_slot) @freeze_time('2022-02-14') @users('apt_manager') def test_different_timezones_with_allday_events_availabilities(self): """ When the utc offset of the timezone is large, it is possible that the day of the week no longer corresponds. Testing that allday event slots are all not available. """ appointment = self.env['appointment.type'].create({ 'appointment_tz': 'Pacific/Auckland', 'appointment_duration': 21, 'assign_method': 'time_auto_assign', 'category': 'recurring', 'location_id': self.staff_user_nz.partner_id.id, 'name': 'New Zealand Appointment', 'max_schedule_days': 14, 'min_cancellation_hours': 1, 'min_schedule_hours': 1, 'slot_ids': [(0, 0, { 'weekday': '1', 'start_hour': 1, 'end_hour': 23, })], 'staff_user_ids': [(4, self.staff_user_nz.id)], }) self._create_meetings( self.staff_user_nz, [(self.reference_monday + timedelta(days=7), self.reference_monday + timedelta(days=7, hours=1), True )]) slots = appointment._get_appointment_slots( appointment.appointment_tz) self.assertSlots( slots, [{'name_formated': 'February 2022', 'month_date': datetime(2022, 2, 1), 'weeks_count': 5, # 31/01 -> 28/02 (06/03) } ], {'enddate': self.global_slots_enddate, 'startdate': self.reference_now_monthweekstart, 'slots_start_hours': [], # first Monday after reference_now 'slots_startdate': self.reference_monday + timedelta(days=7), # only test that day 'slots_enddate': self.reference_monday + timedelta(days=14), 'slots_day_specific': {date(2022, 2, 28): [{'start':1}]} } )