feat(fusion_clock): redesign dashboard — layered, role-aware, gradient cards (dark+light)

- Rework /fusion_clock/dashboard_data into a personal block (everyone)
  plus a team block (team lead = direct reports, manager = org-wide).
  A regular employee's payload never contains another employee's data.
- New OWL stacked layout: gradient KPI cards (Today/Week/OT/Streak),
  Today's Shift, Recent Activity, Upcoming Leave, Recent Penalties; team
  band adds Present/Absent/Late/Pending, roster, and Needs Attention.
- Dark/light via compile-time $o-webclient-color-scheme branching;
  drop the old runtime html.o_dark dashboard block.
- Open the Dashboard menu to group_fusion_clock_user (lead/manager imply).
- Add HttpCase permission/no-leak tests. Bump 3.13.2 -> 3.14.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 02:28:53 -04:00
parent ea4f216c1a
commit fef99809e5
8 changed files with 681 additions and 296 deletions

View File

@@ -5,3 +5,4 @@ from . import test_clock_nfc_kiosk
from . import test_shift_planner
from . import test_photo_retention
from . import test_schedule_driven
from . import test_dashboard

View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
import json
from odoo import fields
from odoo.tests import tagged, HttpCase
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestFusionClockDashboard(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Users = cls.env['res.users']
Emp = cls.env['hr.employee']
g_user = cls.env.ref('fusion_clock.group_fusion_clock_user')
g_lead = cls.env.ref('fusion_clock.group_fusion_clock_team_lead')
g_mgr = cls.env.ref('fusion_clock.group_fusion_clock_manager')
g_internal = cls.env.ref('base.group_user')
def mk_user(login, group):
return Users.create({
'name': login,
'login': login,
'password': login,
'group_ids': [(6, 0, (g_internal + group).ids)],
})
cls.mgr_user = mk_user('fc_mgr', g_mgr)
cls.mgr_emp = Emp.create({
'name': 'Manager Mae', 'user_id': cls.mgr_user.id,
'x_fclk_enable_clock': True,
})
cls.lead_user = mk_user('fc_lead', g_lead)
cls.lead_emp = Emp.create({
'name': 'Lead Leo', 'user_id': cls.lead_user.id,
'x_fclk_enable_clock': True,
})
cls.rep1_user = mk_user('fc_rep1', g_user)
cls.rep1_emp = Emp.create({
'name': 'Report Rita', 'user_id': cls.rep1_user.id,
'parent_id': cls.lead_emp.id, 'x_fclk_enable_clock': True,
})
cls.rep2_user = mk_user('fc_rep2', g_user)
cls.rep2_emp = Emp.create({
'name': 'Report Raj', 'user_id': cls.rep2_user.id,
'parent_id': cls.lead_emp.id, 'x_fclk_enable_clock': True,
})
cls.other_user = mk_user('fc_other', g_user)
cls.other_emp = Emp.create({
'name': 'Outsider Olga', 'user_id': cls.other_user.id,
'parent_id': cls.mgr_emp.id, 'x_fclk_enable_clock': True,
})
# Clock in both of the lead's reports so present_count / roster are
# exercised (and so the leak test proves a clocked-in *sibling* never
# appears in another employee's payload).
Att = cls.env['hr.attendance']
now = fields.Datetime.now()
Att.create({'employee_id': cls.rep1_emp.id, 'check_in': now})
Att.create({'employee_id': cls.rep2_emp.id, 'check_in': now})
def _call(self):
resp = self.url_open(
'/fusion_clock/dashboard_data',
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': {}}),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(resp.status_code, 200)
result = resp.json()['result']
self.assertFalse(isinstance(result, dict) and result.get('error'), msg=result)
return result
# ---- Task 1: personal block ----
def test_employee_sees_only_personal(self):
self.authenticate('fc_rep1', 'fc_rep1')
data = self._call()
self.assertEqual(data['role'], 'employee')
self.assertIsNone(data['team'])
self.assertEqual(data['personal']['employee_name'], 'Report Rita')
for key in ('today_hours', 'week_hours', 'overtime_week', 'ontime_streak', 'shift'):
self.assertIn(key, data['personal'])
# ---- Task 2: team/org scoping ----
def test_team_lead_scoped_to_direct_reports(self):
self.authenticate('fc_lead', 'fc_lead')
data = self._call()
self.assertEqual(data['role'], 'team_lead')
self.assertIsNotNone(data['team'])
self.assertEqual(data['team']['scope'], 'team')
self.assertEqual(data['team']['total_employees'], 2)
for key in ('present_count', 'absent_count', 'on_leave_count',
'late_count', 'pending_reasons', 'pending_approvals', 'clocked_in'):
self.assertIn(key, data['team'])
# both reports are clocked in → present and on the roster
self.assertEqual(data['team']['present_count'], 2)
self.assertEqual(data['team']['absent_count'], 0)
roster_names = {row['employee'] for row in data['team']['clocked_in']}
self.assertEqual(roster_names, {'Report Rita', 'Report Raj'})
def test_manager_sees_org_wide(self):
self.authenticate('fc_mgr', 'fc_mgr')
data = self._call()
self.assertEqual(data['role'], 'manager')
self.assertIsNotNone(data['team'])
self.assertEqual(data['team']['scope'], 'org')
self.assertGreaterEqual(data['team']['total_employees'], 5)
# the two clocked-in reports are counted org-wide too
self.assertGreaterEqual(data['team']['present_count'], 2)
# ---- Task 3: no cross-employee leak + no-employee path ----
def test_no_cross_employee_leak_for_employee(self):
self.authenticate('fc_rep1', 'fc_rep1')
data = self._call()
blob = json.dumps(data)
self.assertIn('Report Rita', blob) # own name present
self.assertNotIn('Report Raj', blob) # sibling absent
self.assertNotIn('Outsider Olga', blob) # unrelated absent
self.assertNotIn('Lead Leo', blob) # lead absent
def test_team_lead_roster_excludes_outsiders(self):
self.authenticate('fc_lead', 'fc_lead')
data = self._call()
blob = json.dumps(data['team'])
self.assertNotIn('Outsider Olga', blob)
self.assertNotIn('Manager Mae', blob)
def test_user_without_employee_gets_error(self):
g_user = self.env.ref('fusion_clock.group_fusion_clock_user')
g_internal = self.env.ref('base.group_user')
self.env['res.users'].create({
'name': 'No Emp', 'login': 'fc_noemp', 'password': 'fc_noemp',
'group_ids': [(6, 0, (g_internal + g_user).ids)],
})
self.authenticate('fc_noemp', 'fc_noemp')
resp = self.url_open(
'/fusion_clock/dashboard_data',
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': {}}),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(resp.status_code, 200)
self.assertIn('error', resp.json()['result'])