Merge: fusion_clock 19.0.5.0.1 code-review hardening + planning module uninstalled on entech
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.5.0.0',
|
'version': '19.0.5.0.1',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -316,8 +316,10 @@ class FusionClockShiftPlanner(http.Controller):
|
|||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
row = Schedule.browse(int(schedule_id or 0))
|
row = Schedule.browse(int(schedule_id or 0))
|
||||||
if row.exists() and row.is_open:
|
if not (row.exists() and row.is_open):
|
||||||
row.unlink()
|
return {'success': False,
|
||||||
|
'message': 'That open shift is no longer available (it may have just been claimed).'}
|
||||||
|
row.unlink()
|
||||||
return {'success': True, 'data': self._load_week_data(week_start)}
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
@http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ class FusionClockScheduleRecurrence(models.Model):
|
|||||||
seed = Schedule.search(
|
seed = Schedule.search(
|
||||||
[('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1)
|
[('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1)
|
||||||
if not seed:
|
if not seed:
|
||||||
# No anchor row -> nothing to repeat; drop the empty rule.
|
# No anchor row left -> nothing to repeat; drop the empty rule.
|
||||||
|
_logger.info(
|
||||||
|
"Fusion Clock: recurrence %s has no shifts left; removing it.", rec.id)
|
||||||
rec.unlink()
|
rec.unlink()
|
||||||
continue
|
continue
|
||||||
anchor = Schedule.search(
|
anchor = Schedule.search(
|
||||||
@@ -150,5 +152,13 @@ class FusionClockScheduleRecurrence(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _cron_generate(self):
|
def _cron_generate(self):
|
||||||
"""Roll every recurrence's horizon forward (called daily)."""
|
"""Roll every recurrence's horizon forward (called daily). Each rule is
|
||||||
self.search([])._generate()
|
isolated in its own savepoint so one bad recurrence can't abort
|
||||||
|
generation for all the others."""
|
||||||
|
for rec in self.search([]):
|
||||||
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
rec._generate()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception(
|
||||||
|
"Fusion Clock: recurrence %s failed to generate; skipping.", rec.id)
|
||||||
|
|||||||
@@ -332,16 +332,21 @@ class FusionClockSchedule(models.Model):
|
|||||||
if not employee.exists() or not date_obj:
|
if not employee.exists() or not date_obj:
|
||||||
raise ValidationError(_("Invalid employee or schedule date."))
|
raise ValidationError(_("Invalid employee or schedule date."))
|
||||||
|
|
||||||
existing = self.search([
|
# The UNIQUE(employee, date) constraint was dropped to allow split
|
||||||
|
# shifts, so a day can hold several rows. The planner cell is the single
|
||||||
|
# authoritative entry for the day: an edit collapses to one row, a clear
|
||||||
|
# removes them all. (Open shifts have no employee, so they never match.)
|
||||||
|
same_day = self.search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
('schedule_date', '=', date_obj),
|
('schedule_date', '=', date_obj),
|
||||||
], limit=1)
|
], order='id')
|
||||||
|
existing = same_day[:1]
|
||||||
old_value = self.fclk_snapshot(existing)
|
old_value = self.fclk_snapshot(existing)
|
||||||
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||||||
|
|
||||||
if parsed.get('clear'):
|
if parsed.get('clear'):
|
||||||
if existing:
|
if same_day:
|
||||||
existing.unlink()
|
same_day.unlink()
|
||||||
new_schedule = self.browse()
|
new_schedule = self.browse()
|
||||||
new_value = ''
|
new_value = ''
|
||||||
else:
|
else:
|
||||||
@@ -373,6 +378,7 @@ class FusionClockSchedule(models.Model):
|
|||||||
if existing:
|
if existing:
|
||||||
existing.write(vals)
|
existing.write(vals)
|
||||||
new_schedule = existing
|
new_schedule = existing
|
||||||
|
(same_day - existing).unlink() # collapse any split rows into one
|
||||||
else:
|
else:
|
||||||
new_schedule = self.create(vals)
|
new_schedule = self.create(vals)
|
||||||
new_value = new_schedule.fclk_display_value()
|
new_value = new_schedule.fclk_display_value()
|
||||||
@@ -446,6 +452,10 @@ class FusionClockSchedule(models.Model):
|
|||||||
schedule = schedule.sudo()
|
schedule = schedule.sudo()
|
||||||
if not schedule:
|
if not schedule:
|
||||||
raise ValidationError(_("Pick a shift to repeat first."))
|
raise ValidationError(_("Pick a shift to repeat first."))
|
||||||
|
# Replace any existing recurrence so we never orphan a rule that would
|
||||||
|
# otherwise keep generating shifts forever.
|
||||||
|
if schedule.recurrence_id:
|
||||||
|
self.fclk_clear_recurrence(schedule)
|
||||||
rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({
|
rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({
|
||||||
'repeat_interval': int(repeat_vals.get('repeat_interval') or 1),
|
'repeat_interval': int(repeat_vals.get('repeat_interval') or 1),
|
||||||
'repeat_unit': repeat_vals.get('repeat_unit') or 'week',
|
'repeat_unit': repeat_vals.get('repeat_unit') or 'week',
|
||||||
@@ -517,6 +527,8 @@ class FusionClockSchedule(models.Model):
|
|||||||
schedule = schedule.sudo()
|
schedule = schedule.sudo()
|
||||||
if not schedule or schedule.employee_id != employee.sudo():
|
if not schedule or schedule.employee_id != employee.sudo():
|
||||||
raise ValidationError(_("You can only release your own shift."))
|
raise ValidationError(_("You can only release your own shift."))
|
||||||
|
if schedule.state != 'posted' or schedule.is_open:
|
||||||
|
raise ValidationError(_("Only a posted shift assigned to you can be released."))
|
||||||
cutoff = schedule.company_id.fclk_self_unassign_days_before or 0
|
cutoff = schedule.company_id.fclk_self_unassign_days_before or 0
|
||||||
if (schedule.schedule_date - fields.Date.today()).days < cutoff:
|
if (schedule.schedule_date - fields.Date.today()).days < cutoff:
|
||||||
raise ValidationError(_("It is too late to release this shift."))
|
raise ValidationError(_("It is too late to release this shift."))
|
||||||
|
|||||||
@@ -428,7 +428,14 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
schedule_id: id,
|
schedule_id: id,
|
||||||
week_start: this.state.weekStart,
|
week_start: this.state.weekStart,
|
||||||
});
|
});
|
||||||
if (!result.error) {
|
if (result.error || result.success === false) {
|
||||||
|
this.notification.add(result.error || result.message || "Could not remove open shift.", {
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
if (result.data) {
|
||||||
|
this._applyData(result.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this._applyData(result.data);
|
this._applyData(result.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class TestOpenShift(TransactionCase):
|
|||||||
self.assertTrue(sch.is_open)
|
self.assertTrue(sch.is_open)
|
||||||
self.assertFalse(sch.employee_id)
|
self.assertFalse(sch.employee_id)
|
||||||
|
|
||||||
|
def test_release_draft_shift_rejected(self):
|
||||||
|
emp = self.env['hr.employee'].create({'name': 'Drafty'})
|
||||||
|
future = date.today() + timedelta(days=30)
|
||||||
|
sch = self.S.create({
|
||||||
|
'employee_id': emp.id, 'schedule_date': future,
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'state': 'draft'})
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.S.fclk_release_shift(sch, emp)
|
||||||
|
|
||||||
def test_bulk_apply_to_many_employees(self):
|
def test_bulk_apply_to_many_employees(self):
|
||||||
e1 = self.env['hr.employee'].create({'name': 'Bulk A'})
|
e1 = self.env['hr.employee'].create({'name': 'Bulk A'})
|
||||||
e2 = self.env['hr.employee'].create({'name': 'Bulk B'})
|
e2 = self.env['hr.employee'].create({'name': 'Bulk B'})
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ class TestRecurrence(TransactionCase):
|
|||||||
self.assertNotIn(date(2026, 6, 8), dates, "Leave day should be skipped")
|
self.assertNotIn(date(2026, 6, 8), dates, "Leave day should be skipped")
|
||||||
self.assertIn(date(2026, 6, 15), dates)
|
self.assertIn(date(2026, 6, 15), dates)
|
||||||
|
|
||||||
|
def test_reattach_recurrence_replaces_old_rule(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule1 = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||||
|
rule1_id = rule1.id
|
||||||
|
rule2 = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 2})
|
||||||
|
# The old rule must be gone (not left generating forever) and the seed
|
||||||
|
# must point at the new rule.
|
||||||
|
self.assertFalse(
|
||||||
|
self.env['fusion.clock.schedule.recurrence'].browse(rule1_id).exists())
|
||||||
|
self.assertEqual(seed.recurrence_id, rule2)
|
||||||
|
|
||||||
def test_clear_recurrence_unlinks_rule_when_empty(self):
|
def test_clear_recurrence_unlinks_rule_when_empty(self):
|
||||||
seed = self._seed(date(2026, 6, 1))
|
seed = self._seed(date(2026, 6, 1))
|
||||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ class TestShiftPlannerModels(TransactionCase):
|
|||||||
self.assertEqual(schedule.planned_hours, 8.0)
|
self.assertEqual(schedule.planned_hours, 8.0)
|
||||||
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
|
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):
|
def test_overnight_range_is_accepted(self):
|
||||||
# Overnight shifts (end on/before start) are supported as of 19.0.5.0.0.
|
# Overnight shifts (end on/before start) are supported as of 19.0.5.0.0.
|
||||||
sch = self.Schedule.create({
|
sch = self.Schedule.create({
|
||||||
|
|||||||
Reference in New Issue
Block a user