fusion_clock doesn't depend on planning, so planning's models load AFTER it during -u and the port saw no data. Now detect planning tables via SQL, defer (no marker) when the ORM isn't loaded, and finish the port from the deploy odoo-shell step (full registry). Marker now owned by the method. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
783 lines
31 KiB
Python
783 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
|
||
import logging
|
||
import re
|
||
from datetime import timedelta
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import ValidationError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class FusionClockSchedule(models.Model):
|
||
_name = 'fusion.clock.schedule'
|
||
_description = 'Clock Shift Schedule Entry'
|
||
_order = 'schedule_date, employee_id'
|
||
_rec_name = 'display_name'
|
||
|
||
employee_id = fields.Many2one(
|
||
'hr.employee',
|
||
string='Employee',
|
||
required=False, # open (unassigned) shifts have no employee until claimed
|
||
index=True,
|
||
ondelete='cascade',
|
||
)
|
||
is_open = fields.Boolean(
|
||
string='Open Shift',
|
||
default=False,
|
||
index=True,
|
||
help="An unassigned shift any eligible employee can claim from the portal.",
|
||
)
|
||
schedule_date = fields.Date(
|
||
string='Date',
|
||
required=True,
|
||
index=True,
|
||
)
|
||
shift_id = fields.Many2one(
|
||
'fusion.clock.shift',
|
||
string='Shift Template',
|
||
ondelete='set null',
|
||
)
|
||
is_off = fields.Boolean(
|
||
string='Off',
|
||
default=False,
|
||
index=True,
|
||
)
|
||
start_time = fields.Float(
|
||
string='Start Time',
|
||
default=9.0,
|
||
)
|
||
end_time = fields.Float(
|
||
string='End Time',
|
||
default=17.0,
|
||
)
|
||
break_minutes = fields.Float(
|
||
string='Break (min)',
|
||
default=30.0,
|
||
)
|
||
planned_hours = fields.Float(
|
||
string='Hours',
|
||
compute='_compute_planned_hours',
|
||
store=True,
|
||
)
|
||
crosses_midnight = fields.Boolean(
|
||
string='Overnight',
|
||
compute='_compute_planned_hours',
|
||
store=True,
|
||
help="Set automatically when the shift ends on the next day "
|
||
"(end time on or before start time).",
|
||
)
|
||
note = fields.Char(string='Note')
|
||
role_id = fields.Many2one(
|
||
'fusion.clock.role',
|
||
string='Role',
|
||
help="Shift role — drives the colour/label shown on the employee's "
|
||
"portal schedule. Defaults from the shift template or the "
|
||
"employee's Default Shift Role.",
|
||
)
|
||
recurrence_id = fields.Many2one(
|
||
'fusion.clock.schedule.recurrence',
|
||
string='Recurrence',
|
||
ondelete='set null',
|
||
index=True,
|
||
help="Set when this entry was generated by a recurring rule.",
|
||
)
|
||
company_id = fields.Many2one(
|
||
'res.company',
|
||
string='Company',
|
||
compute='_compute_fclk_company',
|
||
store=True,
|
||
readonly=False,
|
||
index=True,
|
||
)
|
||
department_id = fields.Many2one(
|
||
'hr.department',
|
||
string='Department',
|
||
related='employee_id.department_id',
|
||
store=True,
|
||
readonly=True,
|
||
)
|
||
display_name = fields.Char(
|
||
compute='_compute_display_name',
|
||
store=True,
|
||
)
|
||
state = fields.Selection(
|
||
[('draft', 'Draft'), ('posted', 'Posted')],
|
||
string='Status',
|
||
default='draft',
|
||
index=True,
|
||
help="Only POSTED entries drive reminders, absence checks and penalties. "
|
||
"Draft entries are ignored by automation until the team lead posts them.",
|
||
)
|
||
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
||
|
||
# No hard UNIQUE(employee, date): the per-day model now allows split shifts
|
||
# and open (unassigned) shifts. The shift planner still manages one cell per
|
||
# day in place; the attendance contract (_get_fclk_day_plan) resolves
|
||
# multiple posted rows into a single work-window.
|
||
|
||
@api.depends('employee_id')
|
||
def _compute_fclk_company(self):
|
||
for rec in self:
|
||
if rec.employee_id:
|
||
rec.company_id = rec.employee_id.company_id
|
||
elif not rec.company_id:
|
||
rec.company_id = self.env.company
|
||
|
||
@api.constrains('employee_id', 'is_open')
|
||
def _check_employee_or_open(self):
|
||
for rec in self:
|
||
if not rec.employee_id and not rec.is_open:
|
||
raise ValidationError(
|
||
_("A shift must have an employee unless it is an open shift."))
|
||
|
||
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||
def _compute_planned_hours(self):
|
||
for rec in self:
|
||
rec.crosses_midnight = False
|
||
if rec.is_off:
|
||
rec.planned_hours = 0.0
|
||
continue
|
||
start = rec.start_time or 0.0
|
||
end = rec.end_time or 0.0
|
||
if end <= start:
|
||
# Overnight: the shift ends on the following day.
|
||
rec.crosses_midnight = True
|
||
raw_hours = (24.0 - start) + end
|
||
else:
|
||
raw_hours = end - start
|
||
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
|
||
|
||
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
|
||
def _compute_display_name(self):
|
||
for rec in self:
|
||
emp = rec.employee_id.name or ''
|
||
date_str = str(rec.schedule_date) if rec.schedule_date else ''
|
||
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
|
||
|
||
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
|
||
def _check_schedule_times(self):
|
||
for rec in self:
|
||
if rec.break_minutes < 0:
|
||
raise ValidationError(_("Break minutes cannot be negative."))
|
||
if rec.is_off:
|
||
continue
|
||
if rec.start_time < 0 or rec.start_time >= 24:
|
||
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||
if rec.end_time < 0 or rec.end_time > 24:
|
||
raise ValidationError(_("End time must be between 00:00 and 24:00."))
|
||
# Overnight shifts (end on/before start) are allowed and span midnight.
|
||
if rec.end_time <= rec.start_time:
|
||
shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0
|
||
else:
|
||
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||
if rec.break_minutes >= shift_minutes:
|
||
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||
|
||
@api.onchange('shift_id')
|
||
def _onchange_shift_id(self):
|
||
for rec in self:
|
||
if rec.shift_id:
|
||
rec.is_off = False
|
||
rec.start_time = rec.shift_id.start_time
|
||
rec.end_time = rec.shift_id.end_time
|
||
rec.break_minutes = rec.shift_id.break_minutes
|
||
|
||
@api.model
|
||
def fclk_float_to_display(self, value):
|
||
value = float(value or 0.0)
|
||
hour = int(value)
|
||
minute = int(round((value - hour) * 60))
|
||
if minute == 60:
|
||
hour += 1
|
||
minute = 0
|
||
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
|
||
display_hour = hour % 12
|
||
if display_hour == 0:
|
||
display_hour = 12
|
||
return f"{display_hour}:{minute:02d} {suffix}"
|
||
|
||
def fclk_display_value(self):
|
||
self.ensure_one()
|
||
if self.is_off:
|
||
return 'OFF'
|
||
return (
|
||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
|
||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
|
||
)
|
||
|
||
@api.model
|
||
def fclk_hours_display(self, hours):
|
||
hours = float(hours or 0.0)
|
||
whole = int(hours)
|
||
minutes = int(round((hours - whole) * 60))
|
||
if minutes == 60:
|
||
whole += 1
|
||
minutes = 0
|
||
return f"{whole}:{minutes:02d}"
|
||
|
||
@api.model
|
||
def _fclk_parse_time_part(self, raw):
|
||
text = (raw or '').strip().lower().replace('.', '')
|
||
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
|
||
if not match:
|
||
raise ValidationError(_("Could not understand time '%s'.") % raw)
|
||
hour = int(match.group(1))
|
||
minute = int(match.group(2) or 0)
|
||
meridiem = match.group(3)
|
||
if minute < 0 or minute > 59:
|
||
raise ValidationError(_("Minutes must be between 00 and 59."))
|
||
if meridiem:
|
||
if hour < 1 or hour > 12:
|
||
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
|
||
if meridiem == 'am':
|
||
hour = 0 if hour == 12 else hour
|
||
else:
|
||
hour = 12 if hour == 12 else hour + 12
|
||
elif hour > 24:
|
||
raise ValidationError(_("Hours must be between 0 and 24."))
|
||
return hour + (minute / 60.0)
|
||
|
||
@api.model
|
||
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
|
||
text = (input_value or '').strip()
|
||
if not text:
|
||
return {'clear': True}
|
||
if text.upper() == 'OFF':
|
||
return {
|
||
'clear': False,
|
||
'is_off': True,
|
||
'shift_id': False,
|
||
'start_time': 0.0,
|
||
'end_time': 0.0,
|
||
'break_minutes': 0.0,
|
||
}
|
||
|
||
normalized = (
|
||
text.replace('–', '-')
|
||
.replace('—', '-')
|
||
.replace(' to ', '-')
|
||
.replace(' TO ', '-')
|
||
)
|
||
parts = [p.strip() for p in normalized.split('-', 1)]
|
||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
|
||
start = self._fclk_parse_time_part(parts[0])
|
||
end = self._fclk_parse_time_part(parts[1])
|
||
if end <= start and end + 12 <= 24:
|
||
end += 12
|
||
if end <= start:
|
||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||
return {
|
||
'clear': False,
|
||
'is_off': False,
|
||
'shift_id': False,
|
||
'start_time': start,
|
||
'end_time': end,
|
||
'break_minutes': float(default_break_minutes or 0.0),
|
||
}
|
||
|
||
@api.model
|
||
def fclk_values_from_planner_payload(self, payload, employee):
|
||
payload = payload or {}
|
||
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
|
||
if payload.get('is_off'):
|
||
return {
|
||
'clear': False,
|
||
'is_off': True,
|
||
'shift_id': False,
|
||
'start_time': 0.0,
|
||
'end_time': 0.0,
|
||
'break_minutes': 0.0,
|
||
}
|
||
return {
|
||
'clear': False,
|
||
'is_off': False,
|
||
'shift_id': False,
|
||
'start_time': float(payload.get('start_time') or 0.0),
|
||
'end_time': float(payload.get('end_time') or 0.0),
|
||
'break_minutes': float(payload.get('break_minutes') or 0.0),
|
||
}
|
||
shift_id = int(payload.get('shift_id') or 0)
|
||
if shift_id:
|
||
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
|
||
if not shift.exists():
|
||
raise ValidationError(_("Selected shift template no longer exists."))
|
||
return {
|
||
'clear': False,
|
||
'shift_id': shift.id,
|
||
'is_off': False,
|
||
'start_time': shift.start_time,
|
||
'end_time': shift.end_time,
|
||
'break_minutes': shift.break_minutes,
|
||
}
|
||
|
||
default_break = employee._get_fclk_break_minutes() if employee else 30.0
|
||
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
|
||
|
||
@api.model
|
||
def fclk_snapshot(self, schedule):
|
||
if not schedule:
|
||
return ''
|
||
return schedule.fclk_display_value()
|
||
|
||
@api.model
|
||
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
|
||
self = self.sudo()
|
||
employee = employee.sudo()
|
||
date_obj = fields.Date.to_date(schedule_date)
|
||
if not employee.exists() or not date_obj:
|
||
raise ValidationError(_("Invalid employee or schedule date."))
|
||
|
||
existing = self.search([
|
||
('employee_id', '=', employee.id),
|
||
('schedule_date', '=', date_obj),
|
||
], limit=1)
|
||
old_value = self.fclk_snapshot(existing)
|
||
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||
|
||
if parsed.get('clear'):
|
||
if existing:
|
||
existing.unlink()
|
||
new_schedule = self.browse()
|
||
new_value = ''
|
||
else:
|
||
# Resolve the role: explicit payload role wins, then the shift
|
||
# template's role, then the employee's default role.
|
||
role_id = payload.get('role_id')
|
||
if not role_id:
|
||
shift_id = parsed.get('shift_id')
|
||
shift = self.env['fusion.clock.shift'].browse(shift_id) if shift_id else None
|
||
if shift and shift.role_id:
|
||
role_id = shift.role_id.id
|
||
elif employee.x_fclk_default_role_id:
|
||
role_id = employee.x_fclk_default_role_id.id
|
||
vals = {
|
||
'employee_id': employee.id,
|
||
'schedule_date': date_obj,
|
||
'shift_id': parsed.get('shift_id') or False,
|
||
'role_id': int(role_id) if role_id else False,
|
||
'is_off': bool(parsed.get('is_off')),
|
||
'start_time': parsed.get('start_time') or 0.0,
|
||
'end_time': parsed.get('end_time') or 0.0,
|
||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||
'note': payload.get('note') or False,
|
||
# Any planner edit returns the cell to draft; it must be re-posted
|
||
# before automation acts on it.
|
||
'state': 'draft',
|
||
'posted_date': False,
|
||
}
|
||
if existing:
|
||
existing.write(vals)
|
||
new_schedule = existing
|
||
else:
|
||
new_schedule = self.create(vals)
|
||
new_value = new_schedule.fclk_display_value()
|
||
|
||
if old_value != new_value:
|
||
self.env['fusion.clock.schedule.audit'].sudo().create({
|
||
'schedule_id': new_schedule.id if new_schedule else False,
|
||
'employee_id': employee.id,
|
||
'schedule_date': date_obj,
|
||
'old_value': old_value,
|
||
'new_value': new_value,
|
||
'changed_by_id': (user or self.env.user).id,
|
||
'changed_at': fields.Datetime.now(),
|
||
'company_id': employee.company_id.id,
|
||
'department_id': employee.department_id.id,
|
||
})
|
||
return new_schedule
|
||
|
||
@api.model
|
||
def fclk_cell_payload(self, employee, date_obj, schedule=None):
|
||
schedule = schedule or self.search([
|
||
('employee_id', '=', employee.id),
|
||
('schedule_date', '=', date_obj),
|
||
], limit=1)
|
||
Schedule = self.env['fusion.clock.schedule']
|
||
if schedule:
|
||
return {
|
||
'schedule_id': schedule.id,
|
||
'source': 'schedule',
|
||
'state': schedule.state,
|
||
'input': schedule.fclk_display_value(),
|
||
'label': schedule.fclk_display_value(),
|
||
'is_off': schedule.is_off,
|
||
'shift_id': schedule.shift_id.id or False,
|
||
'start_time': schedule.start_time,
|
||
'end_time': schedule.end_time,
|
||
'break_minutes': schedule.break_minutes,
|
||
'hours': schedule.planned_hours,
|
||
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
|
||
'note': schedule.note or '',
|
||
'role_id': schedule.role_id.id or False,
|
||
'role_name': schedule.role_id.name or '',
|
||
'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '',
|
||
'recurring': bool(schedule.recurrence_id),
|
||
}
|
||
|
||
plan = employee._get_fclk_day_plan(date_obj)
|
||
return {
|
||
'schedule_id': False,
|
||
'source': plan.get('source') or 'none',
|
||
'state': False,
|
||
'input': plan.get('label') or '',
|
||
'label': plan.get('label') or '',
|
||
'is_off': plan.get('is_off', False),
|
||
'shift_id': False,
|
||
'start_time': plan.get('start_time') or 0.0,
|
||
'end_time': plan.get('end_time') or 0.0,
|
||
'break_minutes': plan.get('break_minutes') or 0.0,
|
||
'hours': plan.get('hours') or 0.0,
|
||
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
|
||
'note': '',
|
||
'role_id': False,
|
||
'role_name': '',
|
||
'role_color': '',
|
||
}
|
||
|
||
@api.model
|
||
def fclk_attach_recurrence(self, schedule, repeat_vals):
|
||
"""Attach a recurrence rule to a seed schedule cell and generate it
|
||
forward. ``repeat_vals`` mirrors the recurrence fields."""
|
||
schedule = schedule.sudo()
|
||
if not schedule:
|
||
raise ValidationError(_("Pick a shift to repeat first."))
|
||
rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({
|
||
'repeat_interval': int(repeat_vals.get('repeat_interval') or 1),
|
||
'repeat_unit': repeat_vals.get('repeat_unit') or 'week',
|
||
'repeat_type': repeat_vals.get('repeat_type') or 'forever',
|
||
'repeat_until': repeat_vals.get('repeat_until') or False,
|
||
'repeat_number': int(repeat_vals.get('repeat_number') or 1),
|
||
'company_id': schedule.company_id.id or self.env.company.id,
|
||
})
|
||
schedule.recurrence_id = rule.id
|
||
rule._generate()
|
||
return rule
|
||
|
||
@api.model
|
||
def fclk_clear_recurrence(self, schedule):
|
||
"""Detach + stop the recurrence on a seed cell (keeps posted rows)."""
|
||
schedule = schedule.sudo()
|
||
rule = schedule.recurrence_id
|
||
if rule:
|
||
rule._stop(fields.Date.today())
|
||
schedule.recurrence_id = False
|
||
if not rule.schedule_ids:
|
||
rule.unlink()
|
||
return True
|
||
|
||
# ----- Open shifts + bulk apply (native "Apply Also To" / self-assign) -----
|
||
|
||
@api.model
|
||
def fclk_create_open_shifts(self, company, date_obj, start, end,
|
||
role_id=False, count=1, break_minutes=0.0, note=None):
|
||
"""Create N open (unassigned) shifts for a day, available to claim."""
|
||
date_obj = fields.Date.to_date(date_obj)
|
||
if not date_obj:
|
||
raise ValidationError(_("Pick a date for the open shift."))
|
||
company_id = (company.id if company else False) or self.env.company.id
|
||
vals_list = [{
|
||
'is_open': True,
|
||
'schedule_date': date_obj,
|
||
'start_time': float(start or 0.0),
|
||
'end_time': float(end or 0.0),
|
||
'break_minutes': float(break_minutes or 0.0),
|
||
'role_id': int(role_id) if role_id else False,
|
||
'company_id': company_id,
|
||
'note': note or False,
|
||
'state': 'posted',
|
||
} for _i in range(max(1, int(count or 1)))]
|
||
return self.sudo().create(vals_list)
|
||
|
||
@api.model
|
||
def fclk_claim_open_shift(self, schedule, employee):
|
||
"""Assign an open shift to an employee (portal self-assign)."""
|
||
schedule = schedule.sudo()
|
||
employee = employee.sudo()
|
||
if not schedule or not schedule.is_open:
|
||
raise ValidationError(_("This shift is no longer available."))
|
||
if not employee:
|
||
raise ValidationError(_("No employee to assign this shift to."))
|
||
# If the shift carries a role and the employee has an explicit allowed
|
||
# list, enforce eligibility (no list = eligible for anything).
|
||
if schedule.role_id and employee.x_fclk_role_ids \
|
||
and schedule.role_id not in employee.x_fclk_role_ids:
|
||
raise ValidationError(_("You are not eligible for this shift's role."))
|
||
schedule.write({'employee_id': employee.id, 'is_open': False})
|
||
return schedule
|
||
|
||
@api.model
|
||
def fclk_release_shift(self, schedule, employee):
|
||
"""Release a claimed shift back to the open pool (portal self-unassign),
|
||
respecting the company's days-before cutoff."""
|
||
schedule = schedule.sudo()
|
||
if not schedule or schedule.employee_id != employee.sudo():
|
||
raise ValidationError(_("You can only release your own shift."))
|
||
cutoff = schedule.company_id.fclk_self_unassign_days_before or 0
|
||
if (schedule.schedule_date - fields.Date.today()).days < cutoff:
|
||
raise ValidationError(_("It is too late to release this shift."))
|
||
schedule.write({'employee_id': False, 'is_open': True})
|
||
return schedule
|
||
|
||
@api.model
|
||
def fclk_bulk_apply(self, employees, date_obj, payload, user=None):
|
||
"""Apply the same shift payload to several employees in one go
|
||
(native replacement for Planning's 'Apply Also To')."""
|
||
results = self.browse()
|
||
for employee in employees:
|
||
results |= self.fclk_apply_planner_cell(employee, date_obj, dict(payload or {}), user)
|
||
return results
|
||
|
||
@api.model
|
||
def fclk_email_posted_range(self, employee, start, end, message=None):
|
||
"""Email one employee a summary of their POSTED shifts between two
|
||
dates (inclusive). Optional ``message`` is shown above the schedule."""
|
||
employee = employee.sudo()
|
||
if not employee.work_email:
|
||
return False
|
||
from .hr_attendance import _fclk_email_wrap
|
||
entries = self.sudo().search([
|
||
('employee_id', '=', employee.id),
|
||
('schedule_date', '>=', start),
|
||
('schedule_date', '<=', end),
|
||
('state', '=', 'posted'),
|
||
])
|
||
by_date = {entry.schedule_date: entry for entry in entries}
|
||
rows = []
|
||
day = start
|
||
while day <= end:
|
||
entry = by_date.get(day)
|
||
rows.append((
|
||
day.strftime('%a %b %d'),
|
||
entry.fclk_display_value() if entry else 'Not scheduled',
|
||
))
|
||
day += timedelta(days=1)
|
||
company = employee.company_id or self.env.company
|
||
summary = (
|
||
f'Hello <strong>{employee.name}</strong>, your shifts for '
|
||
f'<strong>{start.strftime("%b %d")} - {end.strftime("%b %d, %Y")}</strong> '
|
||
f'have been posted.'
|
||
)
|
||
if message:
|
||
summary += f'<br/><br/>{message}'
|
||
body = _fclk_email_wrap(
|
||
company_name=company.name or '',
|
||
title='Your Posted Schedule',
|
||
summary=summary,
|
||
sections=[('Schedule', rows)],
|
||
note='Log in to <a href="/my/clock/schedule" style="color:#10B981;">your portal</a> for details.',
|
||
)
|
||
try:
|
||
mail = self.env['mail.mail'].sudo().create({
|
||
'subject': f'Your schedule: {start.strftime("%b %d")} - {end.strftime("%b %d")}',
|
||
'email_from': company.email or '',
|
||
'email_to': employee.work_email,
|
||
'body_html': body,
|
||
'auto_delete': True,
|
||
})
|
||
mail.send()
|
||
return True
|
||
except Exception as exc:
|
||
_logger.error(
|
||
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
|
||
)
|
||
return False
|
||
|
||
@api.model
|
||
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||
"""Back-compat wrapper — email one employee their posted week."""
|
||
return self.fclk_email_posted_range(employee, week_start, week_end)
|
||
|
||
@api.model
|
||
def fclk_publish_range(self, employees, start, end, message=None):
|
||
"""Post every draft shift in [start, end] for the given employees and
|
||
email each affected employee. Returns (posted_count, notified_count)."""
|
||
Schedule = self.sudo()
|
||
domain = [
|
||
('employee_id', 'in', employees.ids),
|
||
('schedule_date', '>=', start),
|
||
('schedule_date', '<=', end),
|
||
('state', '!=', 'posted'),
|
||
]
|
||
# Never auto-post open (unassigned) shifts (Phase B field).
|
||
if 'is_open' in Schedule._fields:
|
||
domain.append(('is_open', '=', False))
|
||
drafts = Schedule.search(domain)
|
||
posted = len(drafts)
|
||
affected = drafts.mapped('employee_id')
|
||
if drafts:
|
||
drafts.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
|
||
notified = 0
|
||
for employee in affected:
|
||
if Schedule.fclk_email_posted_range(employee, start, end, message=message):
|
||
notified += 1
|
||
return posted, notified
|
||
|
||
@api.model
|
||
def _fclk_port_planning_data(self):
|
||
"""Port Odoo Planning data (roles, employee roles, slots) into the
|
||
native models. Idempotent (marker-guarded). Returns a dict of counts.
|
||
|
||
Because fusion_clock does NOT depend on planning, during a `-u` planning
|
||
may load AFTER us, so its ORM models aren't available in the migration's
|
||
registry. When that happens we set ``deferred`` and do nothing; the
|
||
deploy then runs this again from `odoo shell`, where the whole registry
|
||
(planning included) is loaded. Called by the 19.0.5.0.0 migration, the
|
||
deploy shell step, and tests."""
|
||
import pytz
|
||
|
||
counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0, 'deferred': False}
|
||
env = self.env
|
||
ICP = env['ir.config_parameter'].sudo()
|
||
if ICP.get_param('fusion_clock.planning_migrated'):
|
||
return counts
|
||
|
||
# Do the planning tables exist at all? (raw SQL — independent of whether
|
||
# planning's ORM models are loaded in this registry.)
|
||
env.cr.execute(
|
||
"SELECT to_regclass('public.planning_role'), to_regclass('public.planning_slot')")
|
||
role_tbl, slot_tbl = env.cr.fetchone()
|
||
if not role_tbl and not slot_tbl:
|
||
ICP.set_param('fusion_clock.planning_migrated', '1') # Community / fresh
|
||
return counts
|
||
|
||
# Tables exist but the ORM models may not be loaded yet -> defer.
|
||
if 'planning.slot' not in env or 'planning.role' not in env:
|
||
counts['deferred'] = True
|
||
return counts
|
||
|
||
has_roles = bool(role_tbl)
|
||
has_slots = bool(slot_tbl)
|
||
Role = env['fusion.clock.role'].sudo()
|
||
role_map = {}
|
||
if has_roles:
|
||
for prole in env['planning.role'].sudo().with_context(active_test=False).search([]):
|
||
target = Role.with_context(active_test=False).search(
|
||
[('name', '=ilike', prole.name)], limit=1) or Role.create({
|
||
'name': prole.name, 'color': prole.color or 1, 'active': prole.active})
|
||
role_map[prole.id] = target.id
|
||
counts['roles'] = len(role_map)
|
||
|
||
Employee = env['hr.employee'].sudo().with_context(active_test=False)
|
||
for emp in Employee.search([]):
|
||
vals = {}
|
||
if emp._fields.get('default_planning_role_id') and emp.default_planning_role_id:
|
||
mapped = role_map.get(emp.default_planning_role_id.id)
|
||
if mapped:
|
||
vals['x_fclk_default_role_id'] = mapped
|
||
if emp._fields.get('planning_role_ids') and emp.planning_role_ids:
|
||
mapped_ids = [role_map[r.id] for r in emp.planning_role_ids if r.id in role_map]
|
||
if mapped_ids:
|
||
vals['x_fclk_role_ids'] = [(6, 0, mapped_ids)]
|
||
if vals:
|
||
emp.write(vals)
|
||
counts['employees'] += 1
|
||
|
||
if has_slots:
|
||
Schedule = self.sudo()
|
||
for slot in env['planning.slot'].sudo().search([], order='start_datetime'):
|
||
if not slot.start_datetime or not slot.end_datetime:
|
||
counts['skipped'] += 1
|
||
continue
|
||
employee = slot.employee_id if 'employee_id' in slot._fields else False
|
||
tz_name = ((employee.tz if employee else False)
|
||
or (slot.resource_id.tz if slot.resource_id else False)
|
||
or env.company.partner_id.tz or 'UTC')
|
||
try:
|
||
tz = pytz.timezone(tz_name)
|
||
except Exception:
|
||
tz = pytz.UTC
|
||
local_start = pytz.utc.localize(slot.start_datetime).astimezone(tz)
|
||
local_end = pytz.utc.localize(slot.end_datetime).astimezone(tz)
|
||
span_hours = (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0
|
||
allocated = slot.allocated_hours if 'allocated_hours' in slot._fields else span_hours
|
||
vals = {
|
||
'employee_id': employee.id if employee else False,
|
||
'is_open': not bool(employee),
|
||
'schedule_date': local_start.date(),
|
||
'start_time': round(local_start.hour + local_start.minute / 60.0, 2),
|
||
'end_time': round(local_end.hour + local_end.minute / 60.0, 2),
|
||
'break_minutes': round(max(0.0, span_hours - (allocated or span_hours)) * 60.0, 0),
|
||
'role_id': role_map.get(slot.role_id.id) if slot.role_id else False,
|
||
'note': slot.name or False,
|
||
'state': 'posted' if slot.state == 'published' else 'draft',
|
||
}
|
||
with env.cr.savepoint():
|
||
try:
|
||
Schedule.create(vals)
|
||
counts['slots'] += 1
|
||
except Exception as exc:
|
||
counts['skipped'] += 1
|
||
_logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc)
|
||
|
||
ICP.set_param('fusion_clock.planning_migrated', '1')
|
||
return counts
|
||
|
||
|
||
class FusionClockScheduleAudit(models.Model):
|
||
_name = 'fusion.clock.schedule.audit'
|
||
_description = 'Clock Schedule Change Audit'
|
||
_order = 'changed_at desc, id desc'
|
||
_rec_name = 'display_name'
|
||
|
||
schedule_id = fields.Many2one(
|
||
'fusion.clock.schedule',
|
||
string='Schedule',
|
||
ondelete='set null',
|
||
index=True,
|
||
)
|
||
employee_id = fields.Many2one(
|
||
'hr.employee',
|
||
string='Employee',
|
||
required=True,
|
||
index=True,
|
||
ondelete='cascade',
|
||
)
|
||
schedule_date = fields.Date(
|
||
string='Schedule Date',
|
||
required=True,
|
||
index=True,
|
||
)
|
||
old_value = fields.Char(string='Old Value')
|
||
new_value = fields.Char(string='New Value')
|
||
changed_by_id = fields.Many2one(
|
||
'res.users',
|
||
string='Changed By',
|
||
required=True,
|
||
ondelete='restrict',
|
||
)
|
||
changed_at = fields.Datetime(
|
||
string='Changed At',
|
||
default=fields.Datetime.now,
|
||
required=True,
|
||
index=True,
|
||
)
|
||
company_id = fields.Many2one(
|
||
'res.company',
|
||
string='Company',
|
||
index=True,
|
||
)
|
||
department_id = fields.Many2one(
|
||
'hr.department',
|
||
string='Department',
|
||
index=True,
|
||
)
|
||
display_name = fields.Char(
|
||
compute='_compute_display_name',
|
||
store=True,
|
||
)
|
||
|
||
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
|
||
def _compute_display_name(self):
|
||
for rec in self:
|
||
rec.display_name = "%s - %s: %s -> %s" % (
|
||
rec.employee_id.name or '',
|
||
rec.schedule_date or '',
|
||
rec.old_value or 'blank',
|
||
rec.new_value or 'blank',
|
||
)
|