feat(fusion_clock): native shift roles (fusion.clock.role) [A1-A3]
Replaces Odoo Planning's planning.role: name+colour model with the same 1-11 palette, employee default/allowed role fields, Employee Roles editor, role_id on shift template + schedule with default resolution, ACLs, menus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.4.2.0',
|
||||
'version': '19.0.5.0.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -54,6 +54,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/clock_break_rule_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
'data/clock_recurrence_cron.xml',
|
||||
# Reports (must load before mail templates that reference them)
|
||||
'report/clock_report_template.xml',
|
||||
'report/clock_employee_report.xml',
|
||||
@@ -72,6 +73,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/clock_schedule_views.xml',
|
||||
'views/clock_role_views.xml',
|
||||
'views/clock_recurrence_views.xml',
|
||||
'views/clock_break_rule_views.xml',
|
||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||
'wizard/clock_nfc_enrollment_views.xml',
|
||||
@@ -82,12 +85,14 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/portal_payslip_templates.xml',
|
||||
'views/portal_schedule_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_clock/static/src/css/portal_clock.css',
|
||||
'fusion_clock/static/src/css/portal_schedule.css',
|
||||
'fusion_clock/static/src/scss/nfc_kiosk.scss',
|
||||
'fusion_clock/static/src/scss/pin_kiosk.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
||||
|
||||
@@ -10,6 +10,7 @@ from . import clock_report
|
||||
from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_role
|
||||
from . import clock_shift
|
||||
from . import clock_schedule
|
||||
from . import clock_correction
|
||||
|
||||
46
fusion_clock/models/clock_role.py
Normal file
46
fusion_clock/models/clock_role.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Native shift role. Re-implements the small, useful subset of Odoo
|
||||
# Enterprise ``planning.role`` (name + colour) so Fusion Clock can colour and
|
||||
# label shifts on the portal without depending on the Enterprise Planning app.
|
||||
|
||||
from random import randint
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionClockRole(models.Model):
|
||||
_name = 'fusion.clock.role'
|
||||
_description = 'Clock Shift Role'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
color = fields.Integer(default=_get_default_color)
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# Kanban colour code (1-11) -> hex, mirroring planning.role._get_color_from_code
|
||||
# so the portal Schedule tab shows the same palette Planning used.
|
||||
_COLOR_HEX = {
|
||||
0: '#008784', 1: '#EE4B39', 2: '#F29648', 3: '#F4C609',
|
||||
4: '#55B7EA', 5: '#71405B', 6: '#E86869', 7: '#008784',
|
||||
8: '#267283', 9: '#BF1255', 10: '#2BAF73', 11: '#8754B0',
|
||||
}
|
||||
|
||||
def _get_color_from_code(self, is_open_shift=False):
|
||||
"""Return a hex colour for this role. Open shifts get an '80' alpha
|
||||
suffix (matching Planning's open-shift transparency convention)."""
|
||||
self.ensure_one()
|
||||
hex_value = self._COLOR_HEX.get(self.color, '#008784')
|
||||
return hex_value + ('80' if is_open_shift else '')
|
||||
@@ -58,6 +58,20 @@ class FusionClockSchedule(models.Model):
|
||||
store=True,
|
||||
)
|
||||
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',
|
||||
@@ -292,10 +306,21 @@ class FusionClockSchedule(models.Model):
|
||||
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,
|
||||
@@ -349,6 +374,9 @@ class FusionClockSchedule(models.Model):
|
||||
'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 '',
|
||||
}
|
||||
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
@@ -366,6 +394,9 @@ class FusionClockSchedule(models.Model):
|
||||
'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
|
||||
|
||||
@@ -42,6 +42,12 @@ class FusionClockShift(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
color = fields.Char(string='Color', default='#3B82F6')
|
||||
role_id = fields.Many2one(
|
||||
'fusion.clock.role',
|
||||
string='Default Role',
|
||||
help="Role assigned to shifts created from this template "
|
||||
"(drives the colour/label on the employee's portal schedule).",
|
||||
)
|
||||
|
||||
# Weekday pattern — which days this recurring shift applies as the baseline
|
||||
# when there is no posted planner entry for the day. Default Mon-Fri.
|
||||
|
||||
@@ -33,6 +33,21 @@ class HrEmployee(models.Model):
|
||||
help="Assigned shift schedule. Leave empty to use global defaults.",
|
||||
)
|
||||
|
||||
# Shift roles (native replacement for Odoo Planning's employee role fields)
|
||||
x_fclk_default_role_id = fields.Many2one(
|
||||
'fusion.clock.role',
|
||||
string='Default Shift Role',
|
||||
help="Pre-fills the role on every new shift created for this employee.",
|
||||
)
|
||||
x_fclk_role_ids = fields.Many2many(
|
||||
'fusion.clock.role',
|
||||
'fclk_employee_role_rel',
|
||||
'employee_id',
|
||||
'role_id',
|
||||
string='Allowed Shift Roles',
|
||||
help="Roles this employee is allowed to be scheduled for.",
|
||||
)
|
||||
|
||||
# Pending reason enforcement
|
||||
x_fclk_pending_reason = fields.Boolean(
|
||||
string='Pending Reason Required',
|
||||
|
||||
@@ -28,3 +28,8 @@ access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_sh
|
||||
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_role_user,fusion.clock.role.user,model_fusion_clock_role,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_role_manager,fusion.clock.role.manager,model_fusion_clock_role,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_role_portal,fusion.clock.role.portal,model_fusion_clock_role,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_recurrence_user,fusion.clock.schedule.recurrence.user,model_fusion_clock_schedule_recurrence,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_recurrence_manager,fusion.clock.schedule.recurrence.manager,model_fusion_clock_schedule_recurrence,group_fusion_clock_manager,1,1,1,1
|
||||
|
||||
|
@@ -11,3 +11,10 @@ from . import test_settings
|
||||
from . import test_clock_kiosk
|
||||
from . import test_break_rules
|
||||
from . import test_pending_reason_exempt
|
||||
from . import test_role
|
||||
from . import test_recurrence
|
||||
from . import test_publish_range
|
||||
from . import test_open_shift
|
||||
from . import test_overnight
|
||||
from . import test_multishift_window
|
||||
from . import test_planning_migration
|
||||
|
||||
49
fusion_clock/tests/test_role.py
Normal file
49
fusion_clock/tests/test_role.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestFusionClockRole(TransactionCase):
|
||||
|
||||
def test_default_color_in_range(self):
|
||||
role = self.env['fusion.clock.role'].create({'name': 'Cashier'})
|
||||
self.assertTrue(1 <= role.color <= 11, "Default colour should be 1..11")
|
||||
|
||||
def test_color_hex_and_open_alpha(self):
|
||||
role = self.env['fusion.clock.role'].create({'name': 'Red', 'color': 1})
|
||||
self.assertEqual(role._get_color_from_code(), '#EE4B39')
|
||||
self.assertEqual(role._get_color_from_code(True), '#EE4B3980')
|
||||
|
||||
def test_employee_default_and_allowed_roles(self):
|
||||
lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
|
||||
cashier = self.env['fusion.clock.role'].create({'name': 'Cashier', 'color': 4})
|
||||
emp = self.env['hr.employee'].create({
|
||||
'name': 'Bob',
|
||||
'x_fclk_default_role_id': lead.id,
|
||||
'x_fclk_role_ids': [(6, 0, [lead.id, cashier.id])],
|
||||
})
|
||||
self.assertEqual(emp.x_fclk_default_role_id, lead)
|
||||
self.assertIn(cashier, emp.x_fclk_role_ids)
|
||||
|
||||
def test_schedule_inherits_employee_default_role(self):
|
||||
lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
|
||||
emp = self.env['hr.employee'].create({'name': 'Cara', 'x_fclk_default_role_id': lead.id})
|
||||
sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
|
||||
emp, fields.Date.today(), {'input': '9-5'})
|
||||
self.assertEqual(sch.role_id, lead,
|
||||
"A new shift should inherit the employee's default role")
|
||||
|
||||
def test_schedule_role_from_shift_template(self):
|
||||
stock = self.env['fusion.clock.role'].create({'name': 'Stock', 'color': 5})
|
||||
shift = self.env['fusion.clock.shift'].create({
|
||||
'name': 'Morning', 'start_time': 8.0, 'end_time': 16.0, 'role_id': stock.id})
|
||||
emp = self.env['hr.employee'].create({'name': 'Dan'})
|
||||
sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
|
||||
emp, fields.Date.today(), {'shift_id': shift.id})
|
||||
self.assertEqual(sch.role_id, stock,
|
||||
"Shift-template role should win when employee has no default")
|
||||
@@ -121,6 +121,20 @@
|
||||
sequence="15"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_employee_roles"
|
||||
name="Employee Roles"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
action="action_fclk_employee_role_editor"
|
||||
sequence="17"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_recurrences"
|
||||
name="Recurring Shifts"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
action="action_fusion_clock_recurrence"
|
||||
sequence="18"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_schedule_audit"
|
||||
name="Schedule Audit"
|
||||
parent="menu_fusion_clock_scheduling"
|
||||
@@ -196,6 +210,13 @@
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_roles_config"
|
||||
name="Shift Roles"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_role"
|
||||
sequence="22"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_break_rules"
|
||||
name="Break Rules"
|
||||
parent="menu_fusion_clock_config"
|
||||
|
||||
94
fusion_clock/views/clock_role_views.xml
Normal file
94
fusion_clock/views/clock_role_views.xml
Normal file
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================
|
||||
Shift Roles (native replacement for planning.role)
|
||||
============================================================ -->
|
||||
<record id="view_fusion_clock_role_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.role.list</field>
|
||||
<field name="model">fusion.clock.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="color" widget="integer"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_role_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.role.form</field>
|
||||
<field name="model">fusion.clock.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="color" widget="integer"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_role" model="ir.actions.act_window">
|
||||
<field name="name">Shift Roles</field>
|
||||
<field name="res_model">fusion.clock.role</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Create your first shift role</p>
|
||||
<p>Roles colour and label shifts on each employee's portal schedule
|
||||
(e.g. "Cashier", "Stockroom", "Shift Lead").</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
Employee Roles editor — fast bulk assignment of default/allowed
|
||||
roles per employee (ported from fusion_planning, native fields).
|
||||
============================================================ -->
|
||||
<record id="view_fclk_employee_role_editor_list" model="ir.ui.view">
|
||||
<field name="name">hr.employee.list.fclk.role.editor</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Employee Roles" editable="bottom" multi_edit="1"
|
||||
default_order="department_id, name">
|
||||
<field name="name" readonly="1"/>
|
||||
<field name="job_title" readonly="1" optional="show"/>
|
||||
<field name="department_id" readonly="1" optional="show"/>
|
||||
<field name="x_fclk_default_role_id" string="Default Role"
|
||||
options="{'no_quick_create': True}" widget="many2one"/>
|
||||
<field name="x_fclk_role_ids" string="All Allowed Roles"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': True, 'color_field': 'color'}"
|
||||
optional="show"/>
|
||||
<field name="active" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fclk_employee_role_editor" model="ir.actions.act_window">
|
||||
<field name="name">Employee Roles</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_fclk_employee_role_editor_list"/>
|
||||
<field name="domain">[('active', '=', True)]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Set the Default Role and allowed Roles for each employee
|
||||
</p>
|
||||
<p>Click any cell under <b>Default Role</b> or <b>All Allowed Roles</b>
|
||||
and start typing. The Default Role pre-fills every new shift you
|
||||
create for that employee.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -16,10 +16,12 @@
|
||||
<field name="department_id"/>
|
||||
<field name="is_off"/>
|
||||
<field name="shift_id"/>
|
||||
<field name="role_id" optional="show"/>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="recurrence_id" optional="hide"/>
|
||||
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
@@ -38,6 +40,8 @@
|
||||
<field name="schedule_date"/>
|
||||
<field name="is_off"/>
|
||||
<field name="shift_id"/>
|
||||
<field name="role_id" options="{'no_quick_create': True}"/>
|
||||
<field name="recurrence_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="color" widget="color"/>
|
||||
<field name="role_id" options="{'no_quick_create': True}"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
Reference in New Issue
Block a user