From b4ca85e2919d389047efe9272e71991db80ad983 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:42:04 -0400 Subject: [PATCH] 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) --- fusion_clock/__manifest__.py | 7 +- fusion_clock/models/__init__.py | 1 + fusion_clock/models/clock_role.py | 46 ++++++++++ fusion_clock/models/clock_schedule.py | 31 +++++++ fusion_clock/models/clock_shift.py | 6 ++ fusion_clock/models/hr_employee.py | 15 ++++ fusion_clock/security/ir.model.access.csv | 5 ++ fusion_clock/tests/__init__.py | 7 ++ fusion_clock/tests/test_role.py | 49 +++++++++++ fusion_clock/views/clock_menus.xml | 21 +++++ fusion_clock/views/clock_role_views.xml | 94 +++++++++++++++++++++ fusion_clock/views/clock_schedule_views.xml | 4 + fusion_clock/views/clock_shift_views.xml | 1 + 13 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 fusion_clock/models/clock_role.py create mode 100644 fusion_clock/tests/test_role.py create mode 100644 fusion_clock/views/clock_role_views.xml diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 5e027958..7d92de95 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -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', diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index c51939ad..b0797190 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -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 diff --git a/fusion_clock/models/clock_role.py b/fusion_clock/models/clock_role.py new file mode 100644 index 00000000..b68a4bfb --- /dev/null +++ b/fusion_clock/models/clock_role.py @@ -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 '') diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index e734cf6f..3756fb0f 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -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 diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py index c86ad164..b5bb2dd7 100644 --- a/fusion_clock/models/clock_shift.py +++ b/fusion_clock/models/clock_shift.py @@ -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. diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 1b840320..1c67fe4b 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -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', diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv index 644cecff..240c705d 100644 --- a/fusion_clock/security/ir.model.access.csv +++ b/fusion_clock/security/ir.model.access.csv @@ -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 diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index 49f71366..2af2e0a8 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -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 diff --git a/fusion_clock/tests/test_role.py b/fusion_clock/tests/test_role.py new file mode 100644 index 00000000..781fcffd --- /dev/null +++ b/fusion_clock/tests/test_role.py @@ -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") diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 672ed6b7..3ddfcfa8 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -121,6 +121,20 @@ sequence="15" groups="group_fusion_clock_manager"/> + + + + + + + + + + + fusion.clock.role.list + fusion.clock.role + + + + + + + + + + + + + fusion.clock.role.form + fusion.clock.role + +
+ + + + + + + + + + + + + +
+
+
+ + + Shift Roles + fusion.clock.role + list,form + +

Create your first shift role

+

Roles colour and label shifts on each employee's portal schedule + (e.g. "Cashier", "Stockroom", "Shift Lead").

+
+
+ + + + hr.employee.list.fclk.role.editor + hr.employee + + + + + + + + + + + + + + Employee Roles + hr.employee + list + + [('active', '=', True)] + +

+ Set the Default Role and allowed Roles for each employee +

+

Click any cell under Default Role or All Allowed Roles + and start typing. The Default Role pre-fills every new shift you + create for that employee.

+
+
+ +
diff --git a/fusion_clock/views/clock_schedule_views.xml b/fusion_clock/views/clock_schedule_views.xml index 585c3ccd..ef7260e0 100644 --- a/fusion_clock/views/clock_schedule_views.xml +++ b/fusion_clock/views/clock_schedule_views.xml @@ -16,10 +16,12 @@ + + @@ -38,6 +40,8 @@ + + diff --git a/fusion_clock/views/clock_shift_views.xml b/fusion_clock/views/clock_shift_views.xml index 2e86984f..876b9a34 100644 --- a/fusion_clock/views/clock_shift_views.xml +++ b/fusion_clock/views/clock_shift_views.xml @@ -36,6 +36,7 @@ +