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>
155 lines
6.3 KiB
Python
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)))
|