docs(fusion_clock): bi-weekly attendance filter implementation plan (TDD, 4 tasks)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
# Bi-Weekly Attendance Filter — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let operators scope the All Attendances list to a pay period — one-click Current/Previous/Next Pay-Period filters plus a "Bi-Weekly Period" picker — reusing the module's existing Pay Period setting and date math.
|
||||
|
||||
**Architecture:** Extract the existing period math into one pure helper (`models/pay_period.py`) shared by the report, three search-method computed booleans on `hr.attendance` (→ search-view filters), and a transient picker wizard (→ menu item + dashboard tile). The window follows the configured Frequency (bi-weekly by default).
|
||||
|
||||
**Tech Stack:** Odoo 19, Python (pure helper + ORM search methods + TransientModel), QWeb search/wizard views, OWL dashboard tile, `TransactionCase` tests.
|
||||
|
||||
**Reference (read first):** spec `fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md`; repo-root `CLAUDE.md` + `fusion_clock/CLAUDE.md`.
|
||||
|
||||
**Test command** (substitute `odoo-modsdev-app` if that's your dev container):
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
**Commit discipline (shared working tree):** stage explicit paths, verify `git diff --cached --name-only`, then `git commit --only -- <paths>`. Never `git add -A`. Don't stage `.pyc`/`.DS_Store`. Push to **both** `origin` and `gitea` at the end.
|
||||
|
||||
**File structure:**
|
||||
- `models/pay_period.py` (new) — pure date math: `compute_pay_period`, `period_length_days`, `current_prev_next`.
|
||||
- `models/hr_attendance.py` — 3 computed booleans + search methods (filter backing).
|
||||
- `models/clock_report.py` — `_calculate_current_period` delegates to the helper.
|
||||
- `wizard/clock_period_picker_wizard.py` (new) — transient picker.
|
||||
- `wizard/clock_period_picker_views.xml` (new) — picker form + action.
|
||||
- `views/hr_attendance_views.xml` — 3 filters in the existing search view.
|
||||
- `views/clock_menus.xml` — "Bi-Weekly Period" menu item.
|
||||
- `views/res_config_settings_views.xml` — clarify Anchor Date help.
|
||||
- `static/src/js|xml/fusion_clock_dashboard.*` — dashboard tile.
|
||||
- `tests/test_pay_period.py` (new).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Shared period-math helper + report delegation
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/models/pay_period.py`
|
||||
- Create: `fusion_clock/tests/test_pay_period.py`
|
||||
- Modify: `fusion_clock/models/__init__.py`, `fusion_clock/tests/__init__.py`, `fusion_clock/models/clock_report.py`
|
||||
|
||||
- [ ] **Step 1: Register the new test module**
|
||||
|
||||
In `fusion_clock/tests/__init__.py` add at the end:
|
||||
```python
|
||||
from . import test_pay_period
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing math test**
|
||||
|
||||
Create `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests import tagged, TransactionCase
|
||||
from odoo.addons.fusion_clock.models.pay_period import (
|
||||
compute_pay_period, period_length_days, current_prev_next,
|
||||
)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPayPeriodMath(TransactionCase):
|
||||
|
||||
def test_biweekly_window_is_14_days(self):
|
||||
# anchor Mon 2026-05-04; a date inside the 2nd period
|
||||
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(s, date(2026, 5, 18))
|
||||
self.assertEqual(e, date(2026, 5, 31))
|
||||
self.assertEqual((e - s).days, 13)
|
||||
|
||||
def test_weekly_window_is_7_days(self):
|
||||
s, e = compute_pay_period('weekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(s, date(2026, 5, 18))
|
||||
self.assertEqual(e, date(2026, 5, 24))
|
||||
|
||||
def test_reference_before_anchor(self):
|
||||
# 2026-04-20 is one biweekly period BEFORE the anchor
|
||||
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 4, 25))
|
||||
self.assertEqual(s, date(2026, 4, 20))
|
||||
self.assertEqual(e, date(2026, 5, 3))
|
||||
|
||||
def test_monthly_window(self):
|
||||
s, e = compute_pay_period('monthly', '', date(2026, 2, 10))
|
||||
self.assertEqual(s, date(2026, 2, 1))
|
||||
self.assertEqual(e, date(2026, 2, 28))
|
||||
|
||||
def test_semi_monthly_window(self):
|
||||
s1, e1 = compute_pay_period('semi_monthly', '', date(2026, 3, 10))
|
||||
self.assertEqual((s1, e1), (date(2026, 3, 1), date(2026, 3, 15)))
|
||||
s2, e2 = compute_pay_period('semi_monthly', '', date(2026, 3, 20))
|
||||
self.assertEqual((s2, e2), (date(2026, 3, 16), date(2026, 3, 31)))
|
||||
|
||||
def test_period_length_days(self):
|
||||
self.assertEqual(period_length_days('weekly'), 7)
|
||||
self.assertEqual(period_length_days('biweekly'), 14)
|
||||
self.assertIsNone(period_length_days('monthly'))
|
||||
|
||||
def test_current_prev_next_are_contiguous(self):
|
||||
w = current_prev_next('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(w['current'], (date(2026, 5, 18), date(2026, 5, 31)))
|
||||
self.assertEqual(w['previous'][1], w['current'][0] - __import__('datetime').timedelta(days=1))
|
||||
self.assertEqual(w['next'][0], w['current'][1] + __import__('datetime').timedelta(days=1))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: import error / FAIL — `fusion_clock.models.pay_period` does not exist.
|
||||
|
||||
- [ ] **Step 4: Create the helper**
|
||||
|
||||
Create `fusion_clock/models/pay_period.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Pay-period date math shared by reports, attendance filters and the period
|
||||
picker. Pure functions (no ORM) so they unit-test trivially and never drift
|
||||
between callers."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def period_length_days(frequency):
|
||||
"""Fixed window length for grid frequencies; None for calendar-based ones."""
|
||||
return {'weekly': 7, 'biweekly': 14}.get(frequency)
|
||||
|
||||
|
||||
def compute_pay_period(frequency, anchor_str, reference_date):
|
||||
"""Return (start_date, end_date) for the period containing reference_date.
|
||||
|
||||
``anchor_str`` is a 'YYYY-MM-DD' string or falsy (falls back to
|
||||
first-of-month). Mirrors the original
|
||||
fusion.clock.report._calculate_current_period logic, including floor
|
||||
division so dates before the anchor resolve to the correct earlier period.
|
||||
"""
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = date.fromisoformat(anchor_str)
|
||||
except (ValueError, TypeError):
|
||||
anchor = reference_date.replace(day=1)
|
||||
else:
|
||||
anchor = reference_date.replace(day=1)
|
||||
|
||||
if frequency == 'weekly':
|
||||
period_num = (reference_date - anchor).days // 7
|
||||
start = anchor + timedelta(days=period_num * 7)
|
||||
end = start + timedelta(days=6)
|
||||
elif frequency == 'semi_monthly':
|
||||
if reference_date.day <= 15:
|
||||
start = reference_date.replace(day=1)
|
||||
end = reference_date.replace(day=15)
|
||||
else:
|
||||
start = reference_date.replace(day=16)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
end = next_month - timedelta(days=next_month.day)
|
||||
elif frequency == 'monthly':
|
||||
start = reference_date.replace(day=1)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
end = next_month - timedelta(days=next_month.day)
|
||||
else: # 'biweekly' and default
|
||||
period_num = (reference_date - anchor).days // 14
|
||||
start = anchor + timedelta(days=period_num * 14)
|
||||
end = start + timedelta(days=13)
|
||||
return start, end
|
||||
|
||||
|
||||
def current_prev_next(frequency, anchor_str, today):
|
||||
"""Return {'current','previous','next'} (start,end) windows. Previous/next
|
||||
are derived by stepping the reference date one day outside the current
|
||||
window, which works for grid AND calendar frequencies."""
|
||||
cur = compute_pay_period(frequency, anchor_str, today)
|
||||
prev = compute_pay_period(frequency, anchor_str, cur[0] - timedelta(days=1))
|
||||
nxt = compute_pay_period(frequency, anchor_str, cur[1] + timedelta(days=1))
|
||||
return {'current': cur, 'previous': prev, 'next': nxt}
|
||||
```
|
||||
|
||||
In `fusion_clock/models/__init__.py`, add as the FIRST import (before the model files that use it):
|
||||
```python
|
||||
from . import pay_period
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the math test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: all `TestPayPeriodMath` tests PASS.
|
||||
|
||||
- [ ] **Step 6: Delegate the report method to the helper**
|
||||
|
||||
In `fusion_clock/models/clock_report.py`, replace the entire body of `_calculate_current_period` (the method starting at `def _calculate_current_period(self, schedule_type, period_start_str, reference_date):` through its `return period_start, period_end`) with:
|
||||
```python
|
||||
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
||||
"""Calculate the period start/end dates based on schedule type.
|
||||
|
||||
Delegates to the shared pure helper so reports, the attendance period
|
||||
filters and the picker wizard all use one implementation.
|
||||
"""
|
||||
from .pay_period import compute_pay_period
|
||||
return compute_pay_period(schedule_type, period_start_str, reference_date)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Upgrade to confirm the delegation loads cleanly**
|
||||
|
||||
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20`
|
||||
Expected: no traceback; module loads.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/models/pay_period.py fusion_clock/models/__init__.py fusion_clock/models/clock_report.py fusion_clock/tests/test_pay_period.py fusion_clock/tests/__init__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/models/pay_period.py fusion_clock/models/__init__.py fusion_clock/models/clock_report.py fusion_clock/tests/test_pay_period.py fusion_clock/tests/__init__.py \
|
||||
-m "refactor(fusion_clock): extract pay-period math to shared helper"
|
||||
```
|
||||
(Append the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Period filters on the attendance list
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_attendance.py`, `fusion_clock/views/hr_attendance_views.xml`, `fusion_clock/tests/test_pay_period.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing filter test**
|
||||
|
||||
Append to `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPayPeriodFilters(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from datetime import timedelta
|
||||
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||
# Make TODAY the first day of the current bi-weekly window.
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
self.today = get_local_today(self.env)
|
||||
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
self.ICP.set_param('fusion_clock.pay_period_start', str(self.today))
|
||||
self.emp = self.env['hr.employee'].create({'name': 'Filter Fred'})
|
||||
Att = self.env['hr.attendance']
|
||||
# current period: today .. today+13 -> attendance at 10:00 "now-ish"
|
||||
self.att_current = Att.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': fields.Datetime.now(),
|
||||
'check_out': fields.Datetime.now(),
|
||||
})
|
||||
# previous period: today-14 .. today-1 -> attendance 8 days ago
|
||||
eight_ago = fields.Datetime.now() - timedelta(days=8)
|
||||
self.att_prev = Att.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': eight_ago,
|
||||
'check_out': eight_ago,
|
||||
})
|
||||
|
||||
def test_current_filter_returns_only_current(self):
|
||||
res = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
('x_fclk_in_current_period', '=', True),
|
||||
])
|
||||
self.assertIn(self.att_current, res)
|
||||
self.assertNotIn(self.att_prev, res)
|
||||
|
||||
def test_previous_filter_returns_only_previous(self):
|
||||
res = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
('x_fclk_in_previous_period', '=', True),
|
||||
])
|
||||
self.assertIn(self.att_prev, res)
|
||||
self.assertNotIn(self.att_current, res)
|
||||
```
|
||||
(`fields` is already imported at the top of the file from Task 1? No — add `from odoo import fields` to the test file's imports.)
|
||||
|
||||
In the test file imports (top), ensure:
|
||||
```python
|
||||
from odoo import fields
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: FAIL — `Invalid field 'x_fclk_in_current_period' in leaf ...` (field doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Add the computed fields + search methods**
|
||||
|
||||
In `fusion_clock/models/hr_attendance.py`, add to the imports near the top (the file already imports `get_local_today, get_local_day_boundaries` from `.tz_utils`):
|
||||
```python
|
||||
from .pay_period import current_prev_next
|
||||
```
|
||||
Then add these fields and methods inside the `hr.attendance` model class (place them after the existing `x_fclk_*` field declarations):
|
||||
```python
|
||||
x_fclk_in_current_period = fields.Boolean(
|
||||
string='In Current Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_current_period')
|
||||
x_fclk_in_previous_period = fields.Boolean(
|
||||
string='In Previous Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_previous_period')
|
||||
x_fclk_in_next_period = fields.Boolean(
|
||||
string='In Next Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_next_period')
|
||||
|
||||
def _compute_fclk_period_flags(self):
|
||||
# Display-only; filtering happens entirely in the search methods.
|
||||
for att in self:
|
||||
att.x_fclk_in_current_period = False
|
||||
att.x_fclk_in_previous_period = False
|
||||
att.x_fclk_in_next_period = False
|
||||
|
||||
def _fclk_period_domain(self, which):
|
||||
"""check_in domain for the named pay-period window ('current' /
|
||||
'previous' / 'next'), computed from the configured frequency + anchor."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
frequency = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
start, end = current_prev_next(frequency, anchor, get_local_today(self.env))[which]
|
||||
start_utc, _dummy = get_local_day_boundaries(self.env, start)
|
||||
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, end)
|
||||
return ['&',
|
||||
('check_in', '>=', fields.Datetime.to_string(start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(end_excl_utc))]
|
||||
|
||||
def _search_fclk_in_current_period(self, operator, value):
|
||||
return self._fclk_period_domain('current')
|
||||
|
||||
def _search_fclk_in_previous_period(self, operator, value):
|
||||
return self._fclk_period_domain('previous')
|
||||
|
||||
def _search_fclk_in_next_period(self, operator, value):
|
||||
return self._fclk_period_domain('next')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: `TestPayPeriodFilters` tests PASS.
|
||||
|
||||
- [ ] **Step 5: Add the filters to the search view**
|
||||
|
||||
In `fusion_clock/views/hr_attendance_views.xml`, inside `view_hr_attendance_search_fusion_clock`, replace:
|
||||
```xml
|
||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="fclk_period_current" string="Current Pay Period" domain="[('x_fclk_in_current_period', '=', True)]"/>
|
||||
<filter name="fclk_period_previous" string="Previous Pay Period" domain="[('x_fclk_in_previous_period', '=', True)]"/>
|
||||
<filter name="fclk_period_next" string="Next Pay Period" domain="[('x_fclk_in_next_period', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Upgrade to confirm the view parses**
|
||||
|
||||
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20`
|
||||
Expected: no ParseError.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/models/hr_attendance.py fusion_clock/views/hr_attendance_views.xml fusion_clock/tests/test_pay_period.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/models/hr_attendance.py fusion_clock/views/hr_attendance_views.xml fusion_clock/tests/test_pay_period.py \
|
||||
-m "feat(fusion_clock): Current/Previous/Next Pay Period attendance filters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: "Bi-Weekly Period" picker wizard + menu
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/wizard/clock_period_picker_wizard.py`, `fusion_clock/wizard/clock_period_picker_views.xml`
|
||||
- Modify: `fusion_clock/wizard/__init__.py`, `fusion_clock/__manifest__.py`, `fusion_clock/views/clock_menus.xml`, `fusion_clock/tests/test_pay_period.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing wizard test**
|
||||
|
||||
Append to `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPeriodPickerWizard(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
self.ICP.set_param('fusion_clock.pay_period_start', '2026-05-04')
|
||||
self.today = get_local_today(self.env)
|
||||
|
||||
def test_default_start_is_current_period_start(self):
|
||||
from odoo.addons.fusion_clock.models.pay_period import current_prev_next
|
||||
wiz = self.env['fusion.clock.period.picker'].create({})
|
||||
expected_start = current_prev_next('biweekly', '2026-05-04', self.today)['current'][0]
|
||||
self.assertEqual(wiz.date_start, expected_start)
|
||||
|
||||
def test_onchange_autofills_two_weeks(self):
|
||||
from datetime import date, timedelta
|
||||
wiz = self.env['fusion.clock.period.picker'].new({'date_start': date(2026, 6, 1)})
|
||||
wiz._onchange_date_start()
|
||||
self.assertEqual(wiz.date_end, date(2026, 6, 1) + timedelta(days=13))
|
||||
|
||||
def test_action_apply_returns_attendance_domain(self):
|
||||
from datetime import date
|
||||
wiz = self.env['fusion.clock.period.picker'].create({
|
||||
'date_start': date(2026, 6, 1), 'date_end': date(2026, 6, 14),
|
||||
})
|
||||
act = wiz.action_apply()
|
||||
self.assertEqual(act['res_model'], 'hr.attendance')
|
||||
self.assertEqual(act['view_mode'], 'list,form')
|
||||
leaves = [l for l in act['domain'] if isinstance(l, tuple)]
|
||||
self.assertTrue(any(l[0] == 'check_in' and l[1] == '>=' for l in leaves))
|
||||
self.assertTrue(any(l[0] == 'check_in' and l[1] == '<' for l in leaves))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: FAIL — model `fusion.clock.period.picker` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the wizard model**
|
||||
|
||||
Create `fusion_clock/wizard/clock_period_picker_wizard.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from ..models.pay_period import compute_pay_period, period_length_days, current_prev_next
|
||||
from ..models.tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
|
||||
class FusionClockPeriodPicker(models.TransientModel):
|
||||
"""Pick a pay-period window and open the attendance list filtered to it.
|
||||
|
||||
Defaults to the current pay period. Changing the start auto-fills the end
|
||||
to one pay period later (two weeks by default); the end stays editable so a
|
||||
fully custom range can be entered too.
|
||||
"""
|
||||
_name = 'fusion.clock.period.picker'
|
||||
_description = 'Bi-Weekly Period Picker'
|
||||
|
||||
date_start = fields.Date(string='Period Start', required=True,
|
||||
default=lambda self: self._fclk_default_window()[0])
|
||||
date_end = fields.Date(string='Period End', required=True,
|
||||
default=lambda self: self._fclk_default_window()[1])
|
||||
|
||||
def _fclk_config(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return (ICP.get_param('fusion_clock.pay_period_type', 'biweekly'),
|
||||
ICP.get_param('fusion_clock.pay_period_start', ''))
|
||||
|
||||
def _fclk_default_window(self):
|
||||
frequency, anchor = self._fclk_config()
|
||||
return current_prev_next(frequency, anchor, get_local_today(self.env))['current']
|
||||
|
||||
@api.onchange('date_start')
|
||||
def _onchange_date_start(self):
|
||||
if not self.date_start:
|
||||
return
|
||||
frequency, anchor = self._fclk_config()
|
||||
length = period_length_days(frequency)
|
||||
if length:
|
||||
self.date_end = self.date_start + timedelta(days=length - 1)
|
||||
else:
|
||||
self.date_end = compute_pay_period(frequency, anchor, self.date_start)[1]
|
||||
|
||||
@api.constrains('date_start', 'date_end')
|
||||
def _check_dates(self):
|
||||
for rec in self:
|
||||
if rec.date_start and rec.date_end and rec.date_end < rec.date_start:
|
||||
raise ValidationError(_("Period end cannot be before period start."))
|
||||
|
||||
def action_apply(self):
|
||||
self.ensure_one()
|
||||
start_utc, _dummy = get_local_day_boundaries(self.env, self.date_start)
|
||||
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, self.date_end)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Attendances · %s – %s") % (self.date_start, self.date_end),
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'domain': ['&',
|
||||
('check_in', '>=', fields.Datetime.to_string(start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(end_excl_utc))],
|
||||
'target': 'current',
|
||||
}
|
||||
```
|
||||
|
||||
In `fusion_clock/wizard/__init__.py`, add:
|
||||
```python
|
||||
from . import clock_period_picker_wizard
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the wizard view + action**
|
||||
|
||||
Create `fusion_clock/wizard/clock_period_picker_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fusion_clock_period_picker_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.period.picker.form</field>
|
||||
<field name="model">fusion.clock.period.picker</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Bi-Weekly Period">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
Pick the period start — the end auto-fills to one pay period later
|
||||
(two weeks by default). Adjust either date, then click
|
||||
<b>View Attendances</b>.
|
||||
</div>
|
||||
<group>
|
||||
<field name="date_start"/>
|
||||
<field name="date_end"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_apply" string="View Attendances" type="object" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_period_picker" model="ir.actions.act_window">
|
||||
<field name="name">Bi-Weekly Period</field>
|
||||
<field name="res_model">fusion.clock.period.picker</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fusion_clock_period_picker_form"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
```
|
||||
|
||||
In `fusion_clock/__manifest__.py`, in the `data` list add this line immediately AFTER `'wizard/clock_nfc_enrollment_views.xml',` (it must load before `views/clock_menus.xml`, which references the action):
|
||||
```python
|
||||
'wizard/clock_period_picker_views.xml',
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the menu item**
|
||||
|
||||
In `fusion_clock/views/clock_menus.xml`, add after the `menu_fusion_clock_attendance_list` menuitem (the "All Attendances" item, sequence 10):
|
||||
```xml
|
||||
<menuitem id="menu_fusion_clock_biweekly_period"
|
||||
name="Bi-Weekly Period"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="action_fusion_clock_period_picker"
|
||||
sequence="15"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the wizard test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: `TestPeriodPickerWizard` tests PASS, and the upgrade (triggered by `-u`) loads the new view + menu with no ParseError.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/wizard/clock_period_picker_wizard.py fusion_clock/wizard/clock_period_picker_views.xml fusion_clock/wizard/__init__.py fusion_clock/__manifest__.py fusion_clock/views/clock_menus.xml fusion_clock/tests/test_pay_period.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/wizard/clock_period_picker_wizard.py fusion_clock/wizard/clock_period_picker_views.xml fusion_clock/wizard/__init__.py fusion_clock/__manifest__.py fusion_clock/views/clock_menus.xml fusion_clock/tests/test_pay_period.py \
|
||||
-m "feat(fusion_clock): Bi-Weekly Period picker wizard + Attendance menu item"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Dashboard tile, settings label, version bump, full verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/static/src/js/fusion_clock_dashboard.js`, `fusion_clock/static/src/xml/fusion_clock_dashboard.xml`, `fusion_clock/views/res_config_settings_views.xml`, `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add the dashboard action handler**
|
||||
|
||||
In `fusion_clock/static/src/js/fusion_clock_dashboard.js`, add this method next to the other `onView*` handlers:
|
||||
```javascript
|
||||
onViewBiweekly() { this.action.doAction("fusion_clock.action_fusion_clock_period_picker"); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the dashboard tile**
|
||||
|
||||
In `fusion_clock/static/src/xml/fusion_clock_dashboard.xml`, inside the Quick Actions `<t t-if="state.team">` block, add after the Activity Logs tile:
|
||||
```xml
|
||||
<span class="fclk-dash-act" t-on-click="onViewBiweekly">🗓 Bi-Weekly Period</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Clarify the Anchor Date setting help**
|
||||
|
||||
In `fusion_clock/views/res_config_settings_views.xml`, replace the Pay Period setting's `help` attribute:
|
||||
```xml
|
||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||
help="Defines how often attendance reports are generated and the start/end dates of each reporting period.">
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||
help="Defines how often attendance reports are generated and the start/end dates of each period. The Anchor Date is the pay-period start used by both the reports AND the Bi-Weekly Period filter/picker on the Attendances list.">
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Bump the manifest version**
|
||||
|
||||
In `fusion_clock/__manifest__.py`, change the `version` string to `19.0.3.15.0`.
|
||||
|
||||
- [ ] **Step 5: Full upgrade + run the whole suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: upgrade succeeds; all `test_pay_period` classes pass; existing tests still pass; `0 failed, 0 error`.
|
||||
|
||||
- [ ] **Step 6: Manual browser smoke (local)**
|
||||
|
||||
http://localhost:8082 → Fusion Clock → Attendance → **All Attendances**: open Filters, confirm **Current / Previous / Next Pay Period** appear and each narrows the list. Then Attendance → **Bi-Weekly Period**: the dialog opens with the current period pre-filled; change the start and confirm the end jumps +2 weeks; **View Attendances** opens the list scoped to that window. On the dashboard (as manager), the **🗓 Bi-Weekly Period** tile opens the same dialog.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/static/src/js/fusion_clock_dashboard.js fusion_clock/static/src/xml/fusion_clock_dashboard.xml fusion_clock/views/res_config_settings_views.xml fusion_clock/__manifest__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/static/src/js/fusion_clock_dashboard.js fusion_clock/static/src/xml/fusion_clock_dashboard.xml fusion_clock/views/res_config_settings_views.xml fusion_clock/__manifest__.py \
|
||||
-m "feat(fusion_clock): dashboard Bi-Weekly Period tile + settings note; bump 19.0.3.15.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Push both remotes + deploy entech**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git log origin/main..HEAD --oneline
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
Then deploy to entech (whole module dir): tar (exclude `.superpowers`/`__pycache__`/`*.pyc`/`.DS_Store`) → `scp` to pve-worker5 → `pct push 111` → extract into `/mnt/extra-addons/custom` → `chown -R odoo:odoo` → upgrade as the `odoo` user (`systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo`). Verify `web/login` → 200 and `ir.module.module` version == `19.0.3.15.0`. Hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed inline)
|
||||
|
||||
- **Spec coverage:** §3A shared helper → Task 1; §3B filters → Task 2; §3C wizard → Task 3; §3D menu → Task 3, dashboard tile → Task 4; §3E settings note → Task 4; §6 tests → Tasks 1–3 + Task 4 full run; §9 deploy → Task 4 Step 8.
|
||||
- **Placeholder scan:** none — every code step has complete code; commands have expected output.
|
||||
- **Type/name consistency:** helper API (`compute_pay_period`, `period_length_days`, `current_prev_next` returning `{'current','previous','next'}`) is identical across Task 1 (definition + math test), Task 2 (`_fclk_period_domain` uses `current_prev_next(...)[which]`), and Task 3 (wizard uses all three). Field names `x_fclk_in_current_period` / `_previous_period` / `_next_period` match between the model (Task 2), the filters (Task 2), and the search-method names. Action xmlid `action_fusion_clock_period_picker` matches between the wizard view (Task 3), the menu (Task 3), and the dashboard handler (Task 4). `get_local_day_boundaries` end value used as the exclusive upper bound consistently.
|
||||
- **Scope:** single focused feature; one plan.
|
||||
Reference in New Issue
Block a user