Files
Odoo-Modules/fusion_clock/tests/test_shift_planner.py
gsinghpal 19d484680d fix(fusion_clock): code-review hardening [19.0.5.0.1]
- _cron_generate: per-rule savepoint isolation (one bad rule can't abort the
  whole daily batch)
- fclk_attach_recurrence: clear an existing recurrence first (no orphaned rule
  generating forever)
- fclk_apply_planner_cell: collapse split rows (search was limit=1 after the
  UNIQUE drop, orphaning extras)
- fclk_release_shift: reject non-posted/open shifts (raw-POST guard)
- delete_open_shift: report success=false when nothing was deleted + JS surfaces it
- _generate: log before removing an empty recurrence
Tests added for collapse, re-attach, draft-release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:32:44 -04:00

277 lines
11 KiB
Python

# -*- coding: utf-8 -*-
import json
from datetime import date, timedelta
from odoo import fields
from odoo.tests.common import HttpCase, TransactionCase, tagged
@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_multiple_shifts_per_day_allowed(self):
# The hard one-shift-per-day UNIQUE was dropped in 19.0.5.0.0 to support
# split shifts; the day-plan resolves several rows into one work-window.
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'start_time': 8.0, 'end_time': 12.0,
})
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'start_time': 13.0, 'end_time': 17.0,
})
self.assertEqual(self.Schedule.search_count([
('employee_id', '=', self.employee.id),
('schedule_date', '=', self.schedule_date)]), 2)
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_planner_cell_collapses_split_rows(self):
# Editing a day's planner cell must collapse any split rows into one
# (the planner cell is the authoritative single entry for the day).
d = date(2026, 1, 20)
self.Schedule.create({
'employee_id': self.employee.id, 'schedule_date': d,
'start_time': 8.0, 'end_time': 12.0, 'state': 'posted'})
self.Schedule.create({
'employee_id': self.employee.id, 'schedule_date': d,
'start_time': 13.0, 'end_time': 17.0, 'state': 'posted'})
self.assertEqual(self.Schedule.search_count([
('employee_id', '=', self.employee.id), ('schedule_date', '=', d)]), 2)
self.Schedule.fclk_apply_planner_cell(self.employee, d, {'input': '9-5'})
self.assertEqual(self.Schedule.search_count([
('employee_id', '=', self.employee.id), ('schedule_date', '=', d)]), 1,
"planner edit should collapse split rows to one")
def test_overnight_range_is_accepted(self):
# Overnight shifts (end on/before start) are supported as of 19.0.5.0.0.
sch = 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,
})
self.assertTrue(sch.crosses_midnight)
# 17:00 -> 09:00 = 16h, minus 30m break = 15.5h
self.assertAlmostEqual(sch.planned_hours, 15.5, places=2)
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,
'state': 'posted',
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
# Posted dated entry wins; the next day (no entry) falls back to the
# employee's recurring shift, which now reports source 'shift'.
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'shift')
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)