Files
Odoo-Modules/fusion_clock/controllers/portal_schedule.py
gsinghpal 2ad94070c7 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>
2026-06-04 21:12:10 -04:00

155 lines
6.3 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Portal "My Schedule" tab. Folded in from the retired fusion_planning bridge —
# now reads ONLY the native fusion.clock.schedule (no planning.slot), so it
# works on Community Odoo.
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__)
class FusionClockSchedulePortal(http.Controller):
"""Exposes the employee's published shifts on the portal Schedule tab."""
@http.route('/my/clock/schedule', type='http', auth='user', website=True)
def portal_schedule(self, **kw):
employee = request.env.user.employee_id
if not employee:
return request.redirect('/my')
now_utc = fields.Datetime.now()
today_local = fields.Datetime.context_timestamp(request.env.user, now_utc).date()
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),
('state', '=', 'posted'),
('is_off', '=', False),
('schedule_date', '>=', today_local),
('schedule_date', '<=', horizon_local),
], order='schedule_date asc', limit=200):
day = sch.schedule_date
entries.append((
(day, int(round((sch.start_time or 0.0) * 60))),
day,
{
'day_label': day.strftime('%a').upper(),
'day_num': day.strftime('%d'),
'date_full': day.strftime('%b %d, %Y'),
'time_range': '%s - %s' % (
Schedule.fclk_float_to_display(sch.start_time),
Schedule.fclk_float_to_display(sch.end_time),
),
'duration_hours': round(sch.planned_hours or 0.0, 1),
'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
if delta_days == 0:
bucket_key = 'Today'
elif delta_days == 1:
bucket_key = 'Tomorrow'
elif 0 <= delta_days <= 6:
bucket_key = day.strftime('%A')
else:
bucket_key = day.strftime('%b %d')
groups.setdefault(bucket_key, []).append(item)
next_slot_data = None
if entries:
first = entries[0][2]
next_slot_data = {
'date': entries[0][1].strftime('%a, %b %d'),
'time': first['time_range'].split(' - ')[0],
'role': first['role_name'],
}
values = {
'employee': employee,
'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)))