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:
142
fusion_clock/tests/test_dashboard.py
Normal file
142
fusion_clock/tests/test_dashboard.py
Normal 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'])
|
||||
Reference in New Issue
Block a user