feat(fusion_clock): open shifts + self-assign + bulk apply [B4-B5]

Model: fclk_create_open_shifts/claim_open_shift/release_shift (days-before
cutoff + role eligibility)/bulk_apply. Planner: Open Shift… panel, open-shifts
strip with delete, Apply-to-dept; load includes open shifts. Portal: claim
open shifts + release own upcoming shifts with feedback banners. Tests for
claim/role-gate/release/bulk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:12:10 -04:00
parent 68aaa132ee
commit 2ad94070c7
9 changed files with 514 additions and 1 deletions

View File

@@ -9,8 +9,10 @@
import logging
from collections import OrderedDict
from datetime import timedelta
from urllib.parse import quote
from odoo import http, fields
from odoo.exceptions import ValidationError
from odoo.http import request
_logger = logging.getLogger(__name__)
@@ -30,6 +32,7 @@ class FusionClockSchedulePortal(http.Controller):
horizon_local = today_local + timedelta(days=60)
Schedule = request.env['fusion.clock.schedule'].sudo()
cutoff = employee.company_id.fclk_self_unassign_days_before or 0
entries = []
for sch in Schedule.search([
('employee_id', '=', employee.id),
@@ -54,11 +57,37 @@ class FusionClockSchedulePortal(http.Controller):
'role_name': sch.role_id.name if sch.role_id else '',
'role_color': sch.role_id._get_color_from_code() if sch.role_id else '',
'note': sch.note or '',
'schedule_id': sch.id,
'releasable': (day - today_local).days >= cutoff,
},
))
entries.sort(key=lambda e: e[0])
# Open shifts the employee may claim: company-scoped, future, and either
# role-eligible (allowed-role list contains the shift role) or roleless.
open_shifts = []
for row in Schedule.search([
('is_open', '=', True),
('state', '=', 'posted'),
('company_id', '=', employee.company_id.id),
('schedule_date', '>=', today_local),
('schedule_date', '<=', horizon_local),
], order='schedule_date asc, start_time asc', limit=100):
if row.role_id and employee.x_fclk_role_ids and row.role_id not in employee.x_fclk_role_ids:
continue
d = row.schedule_date
open_shifts.append({
'id': row.id,
'date_full': d.strftime('%a, %b %d'),
'time_range': '%s - %s' % (
Schedule.fclk_float_to_display(row.start_time),
Schedule.fclk_float_to_display(row.end_time),
),
'role_name': row.role_id.name if row.role_id else '',
'duration_hours': round(row.planned_hours or 0.0, 1),
})
groups = OrderedDict()
for _key, day, item in entries:
delta_days = (day - today_local).days
@@ -86,7 +115,40 @@ class FusionClockSchedulePortal(http.Controller):
'groups': groups,
'slot_count': len(entries),
'next_slot': next_slot_data,
'open_shifts': open_shifts,
'error': kw.get('err'),
'success': kw.get('ok'),
'page_name': 'fusion_clock_schedule',
'show_payslips': 'hr.payslip' in request.env,
}
return request.render('fusion_clock.portal_schedule_page', values)
@http.route('/my/clock/schedule/claim', type='http', auth='user',
methods=['POST'], website=True)
def claim_open_shift(self, schedule_id=None, **kw):
employee = request.env.user.employee_id
if not employee or not schedule_id:
return request.redirect('/my/clock/schedule')
Schedule = request.env['fusion.clock.schedule'].sudo()
sch = Schedule.browse(int(schedule_id))
try:
Schedule.fclk_claim_open_shift(sch, employee)
return request.redirect('/my/clock/schedule?ok=claimed')
except ValidationError as exc:
return request.redirect(
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))
@http.route('/my/clock/schedule/release', type='http', auth='user',
methods=['POST'], website=True)
def release_shift(self, schedule_id=None, **kw):
employee = request.env.user.employee_id
if not employee or not schedule_id:
return request.redirect('/my/clock/schedule')
Schedule = request.env['fusion.clock.schedule'].sudo()
sch = Schedule.browse(int(schedule_id))
try:
Schedule.fclk_release_shift(sch, employee)
return request.redirect('/my/clock/schedule?ok=released')
except ValidationError as exc:
return request.redirect(
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))

View File

@@ -79,9 +79,26 @@ class FusionClockShiftPlanner(http.Controller):
('company_id', 'in', request.env.user.company_ids.ids),
], order='sequence, name')
open_rows = Schedule.search([
('is_open', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', days[-1]),
], order='schedule_date, start_time')
open_by_day = {}
for row in open_rows:
open_by_day.setdefault(str(row.schedule_date), []).append({
'id': row.id,
'label': row.fclk_display_value(),
'role_name': row.role_id.name or '',
'role_color': row.role_id._get_color_from_code(True) if row.role_id else '',
'hours_display': Schedule.fclk_hours_display(row.planned_hours),
})
return {
'week_start': str(start),
'week_end': str(days[-1]),
'open_shifts': open_by_day,
'days': [{
'date': str(day),
'weekday': day.strftime('%a').upper(),
@@ -277,6 +294,49 @@ class FusionClockShiftPlanner(http.Controller):
Schedule.fclk_clear_recurrence(schedule)
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/create_open_shift', type='jsonrpc', auth='user', methods=['POST'])
def create_open_shift(self, date=None, start_time=None, end_time=None, role_id=None,
count=1, break_minutes=0.0, week_start=None, **kw):
"""Create one or more open (unassignable) shifts for a day."""
if not self._check_manager():
return {'error': 'Access denied.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
company = request.env.company
try:
Schedule.fclk_create_open_shifts(
company, date, start_time, end_time,
role_id=role_id, count=count, break_minutes=break_minutes)
except ValidationError as exc:
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/delete_open_shift', type='jsonrpc', auth='user', methods=['POST'])
def delete_open_shift(self, schedule_id=None, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
row = Schedule.browse(int(schedule_id or 0))
if row.exists() and row.is_open:
row.unlink()
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST'])
def bulk_apply(self, employee_ids=None, date=None, payload=None, week_start=None, **kw):
"""Apply one shift to several employees at once (Apply Also To)."""
if not self._check_manager():
return {'error': 'Access denied.'}
employees = self._manager_employees()
wanted = {int(eid) for eid in (employee_ids or [])}
employees = employees.filtered(lambda e: e.id in wanted)
if not employees:
return {'success': False, 'message': 'Pick at least one employee.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
try:
Schedule.fclk_bulk_apply(employees, date, payload or {}, request.env.user)
except ValidationError as exc:
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
def export_xlsx(self, week_start=None, **kw):
if not self._check_manager():