Files
Odoo-Modules/fusion_clock/models/clock_recurrence.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

165 lines
7.5 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native recurring-shift engine. The field design and repeat semantics are
# adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N
# days/weeks/months/years; forever / until / N-times), but the generation loop
# targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of
# datetime ``planning.slot`` records — so there is no resource-calendar / DST
# machinery to carry. Generated rows are born ``draft`` and must be posted
# (published) before any attendance automation acts on them.
import logging
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
# Hard safety cap on iterations when projecting a recurrence forward, so a
# misconfigured rule can never loop unbounded (5 years of daily shifts).
_MAX_OCCURRENCES = 365 * 5
class FusionClockScheduleRecurrence(models.Model):
_name = 'fusion.clock.schedule.recurrence'
_description = 'Clock Schedule Recurrence'
_rec_name = 'display_name'
schedule_ids = fields.One2many(
'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts')
repeat_interval = fields.Integer('Repeat Every', default=1, required=True)
repeat_unit = fields.Selection(
[('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')],
string='Repeat Unit', default='week', required=True)
repeat_type = fields.Selection(
[('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')],
string='Until', default='forever', required=True)
repeat_until = fields.Date('Repeat Until')
repeat_number = fields.Integer('Repetitions', default=1)
last_generated_date = fields.Date(readonly=True)
company_id = fields.Many2one(
'res.company', string='Company', required=True,
default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
_check_interval_positive = models.Constraint(
'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.')
@api.constrains('repeat_type', 'repeat_until')
def _check_until(self):
for rec in self:
if rec.repeat_type == 'until' and not rec.repeat_until:
raise ValidationError(_('Set an end date for an "Until" recurrence.'))
@api.constrains('repeat_type', 'repeat_number')
def _check_number(self):
for rec in self:
if rec.repeat_type == 'x_times' and rec.repeat_number < 1:
raise ValidationError(_('The number of repetitions must be at least 1.'))
@api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number')
def _compute_display_name(self):
units = dict(self._fields['repeat_unit'].selection)
for rec in self:
unit = units.get(rec.repeat_unit, rec.repeat_unit)
if rec.repeat_type == 'forever':
rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit)
elif rec.repeat_type == 'until':
rec.display_name = _('Every %(n)s %(u)s until %(d)s',
n=rec.repeat_interval, u=unit, d=rec.repeat_until)
else:
rec.display_name = _('Every %(n)s %(u)s, %(c)s times',
n=rec.repeat_interval, u=unit, c=rec.repeat_number)
def _delta(self, n):
"""relativedelta for the n-th occurrence after the seed."""
self.ensure_one()
key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit]
return relativedelta(**{key: self.repeat_interval * n})
def _horizon(self):
"""Furthest date we pre-generate to when the recurrence has no end."""
self.ensure_one()
months = self.company_id.fclk_planning_generation_months or 6
return fields.Date.today() + relativedelta(months=months)
def _generate(self, stop_date=False):
"""Materialise per-day schedule rows for each recurrence up to its
horizon. Idempotent: dates already covered for the rule are skipped and
``last_generated_date`` advances."""
Schedule = self.env['fusion.clock.schedule'].sudo()
for rec in self:
seed = Schedule.search(
[('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1)
if not seed:
# 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()
continue
anchor = Schedule.search(
[('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1)
bounds = [stop_date or rec._horizon()]
if rec.repeat_until:
bounds.append(rec.repeat_until)
limit = min(bounds)
existing = Schedule.search_count([('recurrence_id', '=', rec.id)])
vals_list, last = [], rec.last_generated_date
for i in range(1, _MAX_OCCURRENCES + 1):
nxt = anchor.schedule_date + rec._delta(i)
if nxt > limit:
break
if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number:
break
if Schedule.search_count(
[('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]):
continue
if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt):
continue
vals_list.append({
'employee_id': anchor.employee_id.id or False,
'schedule_date': nxt,
'shift_id': anchor.shift_id.id or False,
'role_id': anchor.role_id.id or False,
'is_off': anchor.is_off,
# is_open is added in the Phase B schedule extension; guard so
# the engine works whether or not that field exists yet.
'is_open': bool(getattr(anchor, 'is_open', False)),
'start_time': anchor.start_time,
'end_time': anchor.end_time,
'break_minutes': anchor.break_minutes,
'note': anchor.note or False,
'recurrence_id': rec.id,
'state': 'draft',
})
last = nxt
if vals_list:
Schedule.create(vals_list)
rec.last_generated_date = last
def _stop(self, from_date):
"""Delete future DRAFT rows of these rules (posted rows are kept)."""
self.env['fusion.clock.schedule'].sudo().search([
('recurrence_id', 'in', self.ids),
('schedule_date', '>=', from_date),
('state', '=', 'draft'),
]).unlink()
@api.model
def _cron_generate(self):
"""Roll every recurrence's horizon forward (called daily). Each rule is
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)