Portal Timesheets Feature\n- Add new portal timesheets list with edit modal: views/portal_timesheets_templates.xml\n- New controller: controllers/timesheets_portal.py\n- New model for event timesheets: models/sports_event_timesheet.py\n- Security: sports_event_timesheet_rules.xml + ir.model.access.csv updates\n\nEvent/Portal Integration\n- Wire timesheets into events portal: controllers/events_portal.py, views/portal_event_* templates, menus\n- Sports event model enhancements: models/sports_event.py\n\nGeneral Portal/View Updates\n- Updates across player/team/user/partner views for consistency and UX\n\nAsset Cleanup\n- Remove unused 24h datetime initializer from frontend bundles\n * Dropped bemade_sports_clinic/static/src/js/portal_datetime_24h.js from __manifest__.py assets\n\nNotes\n- No functional dependency on the 24h initializer remains; inputs use native datetime-local or explicit formatting in templates.
186 lines
7.8 KiB
Python
186 lines
7.8 KiB
Python
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
|
|
class SportsEventTimesheet(models.Model):
|
|
_name = 'sports.event.timesheet'
|
|
_description = 'Sports Event Timesheet'
|
|
_order = 'coverage_start asc'
|
|
|
|
event_id = fields.Many2one(
|
|
'sports.event',
|
|
string='Event',
|
|
required=True,
|
|
ondelete='cascade',
|
|
index=True,
|
|
help='Event this timesheet belongs to'
|
|
)
|
|
|
|
user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Therapist',
|
|
required=True,
|
|
index=True,
|
|
help='Therapist entering this timesheet'
|
|
)
|
|
|
|
# State
|
|
state = fields.Selection(
|
|
[
|
|
('submitted', 'Submitted'),
|
|
('invoiced', 'Invoiced'),
|
|
],
|
|
string='Status',
|
|
default='submitted',
|
|
index=True,
|
|
help='Submitted: editable; Invoiced: read-only'
|
|
)
|
|
|
|
# Times
|
|
travel_start = fields.Datetime(string='Travel Start')
|
|
coverage_start = fields.Datetime(string='Coverage Start')
|
|
coverage_end = fields.Datetime(string='Coverage End')
|
|
travel_end = fields.Datetime(string='Travel End')
|
|
|
|
# Computed durations
|
|
coverage_duration = fields.Float(
|
|
string='Coverage Duration (Hours)',
|
|
compute='_compute_durations',
|
|
store=True,
|
|
help='Hours of coverage (coverage_end - coverage_start)'
|
|
)
|
|
|
|
travel_duration = fields.Float(
|
|
string='Travel Duration (Hours)',
|
|
compute='_compute_durations',
|
|
store=True,
|
|
help='Total travel time before and after coverage'
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('event_user_unique', 'unique(event_id, user_id)', 'Each therapist may only have one timesheet per event.'),
|
|
]
|
|
|
|
# ------------------------------------------------------
|
|
# DEFAULTS + ONCHANGES (so values show before write)
|
|
# ------------------------------------------------------
|
|
|
|
@api.model
|
|
def default_get(self, fields_list):
|
|
"""Prefill times based on event when opening a fresh record (form or inline)."""
|
|
res = super().default_get(fields_list)
|
|
event_id = self.env.context.get('default_event_id')
|
|
event = self.env['sports.event'].browse(event_id) if event_id else self.env['sports.event']
|
|
|
|
cov_start = event and (event.therapist_start or event.date_start) or False
|
|
cov_end = event and (event.therapist_end or event.date_end) or False
|
|
|
|
if 'event_id' in fields_list and not res.get('event_id') and event_id:
|
|
res['event_id'] = event_id
|
|
if 'user_id' in fields_list and not res.get('user_id'):
|
|
res['user_id'] = self.env.user.id
|
|
if 'state' in fields_list and not res.get('state'):
|
|
res['state'] = 'submitted'
|
|
if 'coverage_start' in fields_list and not res.get('coverage_start'):
|
|
res['coverage_start'] = cov_start
|
|
if 'coverage_end' in fields_list and not res.get('coverage_end'):
|
|
res['coverage_end'] = cov_end
|
|
if 'travel_start' in fields_list and not res.get('travel_start'):
|
|
res['travel_start'] = res.get('coverage_start') or cov_start
|
|
if 'travel_end' in fields_list and not res.get('travel_end'):
|
|
res['travel_end'] = res.get('coverage_end') or cov_end
|
|
return res
|
|
|
|
@api.onchange('event_id')
|
|
def _onchange_event_id(self):
|
|
"""When changing event, prefill times if empty so the user sees defaults immediately."""
|
|
event = self.event_id
|
|
if not event:
|
|
return
|
|
cov_start = event.therapist_start or event.date_start
|
|
cov_end = event.therapist_end or event.date_end
|
|
if not self.coverage_start:
|
|
self.coverage_start = cov_start
|
|
if not self.coverage_end:
|
|
self.coverage_end = cov_end
|
|
if not self.travel_start:
|
|
self.travel_start = self.coverage_start or cov_start
|
|
if not self.travel_end:
|
|
self.travel_end = self.coverage_end or cov_end
|
|
|
|
@api.onchange('coverage_start')
|
|
def _onchange_coverage_start(self):
|
|
"""If travel_start is not set, align it to coverage_start on change."""
|
|
if self.coverage_start and not self.travel_start:
|
|
self.travel_start = self.coverage_start
|
|
|
|
@api.onchange('coverage_end')
|
|
def _onchange_coverage_end(self):
|
|
"""If travel_end is not set, align it to coverage_end on change."""
|
|
if self.coverage_end and not self.travel_end:
|
|
self.travel_end = self.coverage_end
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
# Default times from the event if not provided
|
|
for vals in vals_list:
|
|
event = None
|
|
if vals.get('event_id'):
|
|
event = self.env['sports.event'].browse(vals['event_id'])
|
|
# Default coverage to therapist times
|
|
if event:
|
|
if not vals.get('coverage_start'):
|
|
vals['coverage_start'] = event.therapist_start or event.date_start
|
|
if not vals.get('coverage_end'):
|
|
vals['coverage_end'] = event.therapist_end or event.date_end
|
|
# Default travel to coverage if not provided
|
|
if not vals.get('travel_start'):
|
|
vals['travel_start'] = vals.get('coverage_start')
|
|
if not vals.get('travel_end'):
|
|
vals['travel_end'] = vals.get('coverage_end')
|
|
# Ensure default state
|
|
vals.setdefault('state', 'submitted')
|
|
return super().create(vals_list)
|
|
|
|
def write(self, vals):
|
|
# Prevent editing invoiced records (read-only)
|
|
if any(rec.state == 'invoiced' for rec in self):
|
|
# Allow no-op state write (e.g., writing the same state) and block changes to other fields
|
|
blocked_fields = set(vals.keys()) - {'state'}
|
|
if blocked_fields or (vals.get('state') and any(rec.state == 'invoiced' and vals.get('state') != 'invoiced' for rec in self)):
|
|
raise ValidationError('Timesheets are read-only once invoiced.')
|
|
return super().write(vals)
|
|
|
|
def unlink(self):
|
|
if any(rec.state == 'invoiced' for rec in self):
|
|
raise ValidationError('Invoiced timesheets cannot be deleted.')
|
|
return super().unlink()
|
|
|
|
@api.depends('coverage_start', 'coverage_end', 'travel_start', 'travel_end')
|
|
def _compute_durations(self):
|
|
for ts in self:
|
|
# Coverage duration
|
|
if ts.coverage_start and ts.coverage_end and ts.coverage_end > ts.coverage_start:
|
|
cov = (ts.coverage_end - ts.coverage_start).total_seconds() / 3600.0
|
|
else:
|
|
cov = 0.0
|
|
# Travel before and after coverage
|
|
before = 0.0
|
|
after = 0.0
|
|
if ts.travel_start and ts.coverage_start and ts.coverage_start > ts.travel_start:
|
|
before = (ts.coverage_start - ts.travel_start).total_seconds() / 3600.0
|
|
if ts.travel_end and ts.coverage_end and ts.travel_end > ts.coverage_end:
|
|
after = (ts.travel_end - ts.coverage_end).total_seconds() / 3600.0
|
|
ts.coverage_duration = cov
|
|
ts.travel_duration = max(0.0, before) + max(0.0, after)
|
|
|
|
@api.constrains('coverage_start', 'coverage_end', 'travel_start', 'travel_end')
|
|
def _check_times(self):
|
|
for ts in self:
|
|
# Basic ordering constraints
|
|
if ts.coverage_start and ts.coverage_end and ts.coverage_end <= ts.coverage_start:
|
|
raise ValidationError('Coverage end must be after coverage start.')
|
|
if ts.travel_start and ts.coverage_start and ts.travel_start > ts.coverage_start:
|
|
raise ValidationError('Travel start must be on or before coverage start.')
|
|
if ts.travel_end and ts.coverage_end and ts.travel_end < ts.coverage_end:
|
|
raise ValidationError('Travel end must be on or after coverage end.')
|