# -*- coding: utf-8 -*- import json from datetime import date, timedelta from psycopg2 import IntegrityError from odoo import fields from odoo.exceptions import ValidationError from odoo.tests.common import HttpCase, TransactionCase, tagged from odoo.tools.misc import mute_logger @tagged('-at_install', 'post_install', 'fusion_clock') class TestShiftPlannerModels(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.Schedule = cls.env['fusion.clock.schedule'].sudo() cls.Shift = cls.env['fusion.clock.shift'].sudo() cls.employee = cls.env['hr.employee'].sudo().create({ 'name': 'Planner Model Employee', 'company_id': cls.env.company.id, 'x_fclk_enable_clock': True, }) cls.default_shift = cls.Shift.create({ 'name': 'Default Planner Shift', 'start_time': 8.0, 'end_time': 16.5, 'break_minutes': 30, 'company_id': cls.env.company.id, }) cls.employee.x_fclk_shift_id = cls.default_shift.id cls.schedule_date = date(2026, 1, 5) def test_unique_employee_date_schedule(self): self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': self.schedule_date, 'is_off': True, }) with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): with self.env.cr.savepoint(): self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': self.schedule_date, 'is_off': True, }) def test_off_schedule_has_zero_hours(self): schedule = self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': date(2026, 1, 6), 'is_off': True, }) self.assertEqual(schedule.planned_hours, 0) self.assertEqual(schedule.fclk_display_value(), 'OFF') def test_working_schedule_computes_hours_minus_break(self): schedule = self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': date(2026, 1, 7), 'start_time': 9.0, 'end_time': 17.5, 'break_minutes': 30, }) self.assertEqual(schedule.planned_hours, 8.0) self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00') def test_invalid_same_day_range_is_rejected(self): with self.assertRaises(ValidationError): self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': date(2026, 1, 8), 'start_time': 17.0, 'end_time': 9.0, 'break_minutes': 30, }) def test_apply_planner_cell_creates_audit(self): schedule_date = date(2026, 1, 9) self.Schedule.fclk_apply_planner_cell( self.employee, schedule_date, {'input': '9:00 am - 5:30 pm'}, self.env.user, ) audit = self.env['fusion.clock.schedule.audit'].sudo().search([ ('employee_id', '=', self.employee.id), ('schedule_date', '=', schedule_date), ], limit=1) self.assertTrue(audit) self.assertFalse(audit.old_value) self.assertEqual(audit.new_value, '9:00 am - 5:30 pm') def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self): planned_date = date(2026, 1, 12) self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': planned_date, 'start_time': 10.0, 'end_time': 18.0, 'break_minutes': 60, }) planned = self.employee._get_fclk_day_plan(planned_date) fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1)) self.assertEqual(planned['source'], 'schedule') self.assertEqual(planned['start_time'], 10.0) self.assertEqual(planned['hours'], 7.0) self.assertEqual(fallback['source'], 'fallback') self.assertEqual(fallback['start_time'], 8.0) @tagged('-at_install', 'post_install', 'fusion_clock') class TestShiftPlannerApi(HttpCase): @classmethod def setUpClass(cls): super().setUpClass() manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager') user_group = cls.env.ref('fusion_clock.group_fusion_clock_user') cls.manager_user = cls.env['res.users'].sudo().create({ 'name': 'Planner Manager', 'login': 'planner-manager', 'password': 'plannerpass', 'company_id': cls.env.company.id, 'company_ids': [(6, 0, [cls.env.company.id])], 'group_ids': [(6, 0, [manager_group.id])], }) cls.employee_user = cls.env['res.users'].sudo().create({ 'name': 'Planner Employee User', 'login': 'planner-employee-user', 'password': 'plannerpass', 'company_id': cls.env.company.id, 'company_ids': [(6, 0, [cls.env.company.id])], 'group_ids': [(6, 0, [user_group.id])], 'tz': 'UTC', }) cls.employee = cls.env['hr.employee'].sudo().create({ 'name': 'Planner API Employee', 'user_id': cls.employee_user.id, 'company_id': cls.env.company.id, 'x_fclk_enable_clock': True, }) cls.shift = cls.env['fusion.clock.shift'].sudo().create({ 'name': 'API Morning', 'start_time': 7.0, 'end_time': 15.5, 'break_minutes': 30, 'company_id': cls.env.company.id, }) cls.week_start = '2026-01-19' def _json_call(self, route, payload, login='planner-manager'): self.authenticate(login, 'plannerpass') response = self.url_open( route, data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}), headers={'Content-Type': 'application/json'}, ) return response.json().get('result', {}) def test_manager_can_load_save_and_export_planner(self): load_result = self._json_call('/fusion_clock/shift_planner/load', { 'week_start': self.week_start, }) self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']]) save_result = self._json_call('/fusion_clock/shift_planner/save', { 'week_start': self.week_start, 'changes': [{ 'employee_id': self.employee.id, 'date': self.week_start, 'input': '9-5', 'shift_id': False, }], }) self.assertTrue(save_result.get('success')) schedule = self.env['fusion.clock.schedule'].sudo().search([ ('employee_id', '=', self.employee.id), ('schedule_date', '=', fields.Date.to_date(self.week_start)), ], limit=1) self.assertTrue(schedule) self.assertEqual(schedule.start_time, 9.0) self.assertEqual(schedule.end_time, 17.0) export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', { 'week_start': self.week_start, }) self.assertTrue(export_result.get('success')) self.assertTrue(export_result.get('url', '').startswith('/web/content/')) self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists()) def test_copy_previous_week(self): previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7) self.env['fusion.clock.schedule'].sudo().create({ 'employee_id': self.employee.id, 'schedule_date': previous_monday, 'shift_id': self.shift.id, 'start_time': self.shift.start_time, 'end_time': self.shift.end_time, 'break_minutes': self.shift.break_minutes, }) result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', { 'week_start': self.week_start, }) self.assertTrue(result.get('success')) copied = self.env['fusion.clock.schedule'].sudo().search([ ('employee_id', '=', self.employee.id), ('schedule_date', '=', fields.Date.to_date(self.week_start)), ], limit=1) self.assertEqual(copied.shift_id, self.shift) def test_non_manager_cannot_mutate_planner(self): result = self._json_call('/fusion_clock/shift_planner/save', { 'week_start': self.week_start, 'changes': [], }, login='planner-employee-user') self.assertEqual(result.get('error'), 'Access denied.') def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self): today = fields.Date.today() location = self.env['fusion.clock.location'].sudo().create({ 'name': 'Planner Test Location', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100, 'company_id': self.env.company.id, 'all_employees': True, }) self.env['fusion.clock.schedule'].sudo().create({ 'employee_id': self.employee.id, 'schedule_date': today, 'is_off': True, }) result = self._json_call('/fusion_clock/clock_action', { 'latitude': location.latitude, 'longitude': location.longitude, 'source': 'portal', }, login='planner-employee-user') self.assertTrue(result.get('success')) self.assertEqual(result.get('action'), 'clock_in') self.assertIn('unscheduled', result.get('message', '')) log = self.env['fusion.clock.activity.log'].sudo().search([ ('employee_id', '=', self.employee.id), ('log_type', '=', 'unscheduled_shift'), ], limit=1) self.assertTrue(log)