From cbd9f2faab2b0d6a216b1a9a2ddc1340cd334143 Mon Sep 17 00:00:00 2001 From: Denis Durepos Date: Mon, 22 Sep 2025 21:33:57 -0400 Subject: [PATCH] feat(portal): add timesheets portal (list + edit) and clean assets 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. --- bemade_sports_clinic/__manifest__.py | 3 + bemade_sports_clinic/controllers/__init__.py | 1 + .../controllers/events_portal.py | 125 +++++++- .../controllers/player_management_portal.py | 14 +- .../controllers/team_staff_portal.py | 4 + .../controllers/timesheets_portal.py | 220 +++++++++++++ bemade_sports_clinic/models/__init__.py | 1 + bemade_sports_clinic/models/sports_event.py | 122 +++++++- .../models/sports_event_timesheet.py | 186 +++++++++++ .../security/ir.model.access.csv | 3 + .../security/sports_event_timesheet_rules.xml | 52 ++++ .../views/events_portal_templates.xml | 2 +- .../views/portal_event_detail_template.xml | 139 ++++++++- .../views/portal_event_edit_template.xml | 23 +- .../views/portal_timesheets_templates.xml | 273 ++++++++++++++++ .../views/res_partner_views.xml | 3 + .../views/res_users_views.xml | 3 +- .../views/sports_clinic_menus.xml | 54 +++- .../views/sports_clinic_portal_views.xml | 19 ++ .../views/sports_event_views.xml | 86 ++++- .../views/sports_patient_injury_views.xml | 2 + .../views/sports_patient_views.xml | 1 - .../views/sports_team_views.xml | 294 +++++++++--------- .../team_role_mass_assign_wizard_views.xml | 2 + .../views/treatment_note_views.xml | 3 +- 25 files changed, 1442 insertions(+), 193 deletions(-) create mode 100644 bemade_sports_clinic/controllers/timesheets_portal.py create mode 100644 bemade_sports_clinic/models/sports_event_timesheet.py create mode 100644 bemade_sports_clinic/security/sports_event_timesheet_rules.xml create mode 100644 bemade_sports_clinic/views/portal_timesheets_templates.xml diff --git a/bemade_sports_clinic/__manifest__.py b/bemade_sports_clinic/__manifest__.py index b07fa4c..4779936 100644 --- a/bemade_sports_clinic/__manifest__.py +++ b/bemade_sports_clinic/__manifest__.py @@ -50,6 +50,7 @@ This module provides a complete sports medicine clinic management solution with "phone_validation", # For phone number formatting in patient contacts "project", # Required for project.task (Events) functionality "account", # Ensure account portal templates (e.g., portal_my_home_invoice) are available + "hr_timesheet", # Ensure our portal card override loads after core timesheet portal ], "external_dependencies": { "python": [ @@ -67,6 +68,7 @@ This module provides a complete sports medicine clinic management solution with "security/project_task_portal_rules.xml", "security/sports_event_rules.xml", "security/partner_access.xml", + "security/sports_event_timesheet_rules.xml", "data/sports_clinic_data.xml", "data/admin_access_data.xml", # "data/project_portal_demo_data.xml", # Temporarily disabled for clean upgrade @@ -90,6 +92,7 @@ This module provides a complete sports medicine clinic management solution with "views/portal_event_detail_template.xml", "views/portal_event_edit_template.xml", "views/portal_event_create_template.xml", + "views/portal_timesheets_templates.xml", "views/treatment_note_views.xml", "views/res_partner_views.xml", "views/task_to_event_wizard_views.xml", diff --git a/bemade_sports_clinic/controllers/__init__.py b/bemade_sports_clinic/controllers/__init__.py index 74f7307..08e5212 100644 --- a/bemade_sports_clinic/controllers/__init__.py +++ b/bemade_sports_clinic/controllers/__init__.py @@ -5,3 +5,4 @@ from . import team_management_portal from . import player_management_portal from . import task_management_portal from . import events_portal +from . import timesheets_portal diff --git a/bemade_sports_clinic/controllers/events_portal.py b/bemade_sports_clinic/controllers/events_portal.py index 5eb1da6..a397109 100644 --- a/bemade_sports_clinic/controllers/events_portal.py +++ b/bemade_sports_clinic/controllers/events_portal.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from .access_control_mixin import AccessControlMixin import logging import pytz +import urllib.parse _logger = logging.getLogger(__name__) @@ -391,14 +392,128 @@ class EventsPortal(CustomerPortal, AccessControlMixin): # Check if user can edit (only therapists) can_edit = is_therapist + # Helper to format dt for datetime-local inputs in user's tz + def _format_dt_local(dt): + if not dt: + return '' + try: + tz_name = (http.request.context.get('tz') if http.request and http.request.context else None) or \ + (http.request.env.user.tz if http.request else None) or 'UTC' + user_tz = pytz.timezone(tz_name) + except Exception: + user_tz = pytz.UTC + # Odoo stores as UTC-naive; localize to UTC first + if dt.tzinfo is None: + utc_dt = pytz.UTC.localize(dt) + else: + utc_dt = dt.astimezone(pytz.UTC) + local_dt = utc_dt.astimezone(user_tz) + return local_dt.strftime('%Y-%m-%dT%H:%M') + + # Timesheet context for current user + my_ts = http.request.env['sports.event.timesheet'].search([ + ('event_id', '=', event.id), + ('user_id', '=', user.id), + ], limit=1) + has_my_timesheet = bool(my_ts) + + # Missing timesheets info + missing_users = event._get_missing_timesheet_user_ids() if hasattr(event, '_get_missing_timesheet_user_ids') else http.request.env['res.users'] + missing_count = len(missing_users) + missing_names = ', '.join(missing_users.mapped('name')) if missing_users else '' + values = { 'event': event, 'page_name': 'event_detail', 'can_edit': can_edit, + # Timesheet UI context + 'has_my_timesheet': has_my_timesheet, + 'my_timesheet': my_ts, + # Default local times for modal fields (fallback to therapist/event range) + 'ts_travel_start_local': _format_dt_local(my_ts.travel_start if my_ts else (event.therapist_start or event.date_start)), + 'ts_coverage_start_local': _format_dt_local(my_ts.coverage_start if my_ts else (event.therapist_start or event.date_start)), + 'ts_coverage_end_local': _format_dt_local(my_ts.coverage_end if my_ts else (event.therapist_end or event.date_end)), + 'ts_travel_end_local': _format_dt_local(my_ts.travel_end if my_ts else (event.therapist_end or event.date_end)), + # Completion warnings + 'missing_count': missing_count, + 'missing_names': missing_names, } return http.request.render('bemade_sports_clinic.portal_event_detail', values) + @http.route(['/my/event//timesheet/add'], type='http', auth='user', website=True, methods=['POST'], csrf=False) + def add_timesheet(self, event_id, **post): + """Create or update the current user's timesheet for the event""" + user = http.request.env.user + # Access: therapists only for adding timesheets (or system) + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + if not (is_therapist or user.has_group('base.group_system')): + raise AccessError(_("You don't have permission to add timesheets.")) + + event = http.request.env['sports.event'].browse(event_id) + if not event.exists(): + return http.request.not_found() + + # Build values + vals = { + 'event_id': event.id, + 'user_id': user.id, + } + for key, field_name in [('travel_start', 'travel_start'), + ('coverage_start', 'coverage_start'), + ('coverage_end', 'coverage_end'), + ('travel_end', 'travel_end')]: + if post.get(key): + vals[field_name] = self._parse_portal_datetime(post.get(key)) + + # Create or update existing timesheet for this user + ts_model = http.request.env['sports.event.timesheet'] + existing = ts_model.search([('event_id', '=', event.id), ('user_id', '=', user.id)], limit=1) + try: + if existing: + existing.write(vals) + ts_id = existing.id + else: + ts = ts_model.create(vals) + ts_id = ts.id + return http.request.redirect(f'/my/event/{event.id}?ts_saved=1') + except Exception as e: + msg = str(e).replace('\n', ' ').replace('\r', ' ') + return http.request.redirect(f'/my/event/{event.id}?ts_error={msg}') + + @http.route(['/my/event//mark_complete'], type='http', auth='user', website=True, methods=['POST'], csrf=False) + def mark_event_complete(self, event_id, **post): + """Mark an event completed with non-blocking warning if timesheets missing""" + user = http.request.env.user + is_therapist = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or \ + user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + if not (is_therapist or user.has_group('base.group_system')): + raise AccessError(_("You don't have permission to complete events.")) + + event = http.request.env['sports.event'].browse(event_id) + if not event.exists(): + return http.request.not_found() + + force = post.get('force') + missing_users = event._get_missing_timesheet_user_ids() if hasattr(event, '_get_missing_timesheet_user_ids') else http.request.env['res.users'] + if missing_users and not force: + names = ', '.join(missing_users.mapped('name')) + # Non-blocking: redirect back with warning + return http.request.redirect(f"/my/event/{event.id}?warn_missing=1&missing={urllib.parse.quote(names)}") + + # Perform completion (sudo not needed if model allows write; internal only in model guard but here we allow portal therapists via controller action) + try: + event.write({'state': 'completed'}) + try: + event.message_post(body=_('Event marked Completed via portal')) + except Exception: + pass + return http.request.redirect(f'/my/event/{event.id}?completed=1') + except Exception as e: + msg = str(e).replace('\n', ' ').replace('\r', ' ') + return http.request.redirect(f'/my/event/{event.id}?error={msg}') + @http.route(['/my/event//edit'], type='http', auth='user', website=True) def edit_event_form(self, event_id, **kw): """Edit event form - only accessible to therapists""" @@ -489,8 +604,9 @@ class EventsPortal(CustomerPortal, AccessControlMixin): update_vals['venue_id'] = int(post['venue_id']) if 'event_type' in post: update_vals['event_type'] = post['event_type'] - if 'state' in post: - update_vals['state'] = post['state'] + # NOTE: Workflow is internal-only for now. Do not accept 'state' from portal. + # TODO(bemade_sports_clinic): Implement therapist portal actions to change state + # (e.g., mark in progress, mark completed) with proper access checks. if 'date_start' in post and post['date_start']: update_vals['date_start'] = self._parse_portal_datetime(post['date_start']) @@ -598,8 +714,9 @@ class EventsPortal(CustomerPortal, AccessControlMixin): create_vals['venue_id'] = int(post['venue_id']) if 'event_type' in post: create_vals['event_type'] = post['event_type'] - if 'state' in post: - create_vals['state'] = post['state'] + # NOTE: Workflow is internal-only for now. Do not accept 'state' from portal. + # TODO(bemade_sports_clinic): Implement therapist portal actions to change state + # (e.g., mark in progress, mark completed) with proper access checks. # Datetime fields def _parse_dt(val): diff --git a/bemade_sports_clinic/controllers/player_management_portal.py b/bemade_sports_clinic/controllers/player_management_portal.py index 2c3f4ed..9c5aeaf 100644 --- a/bemade_sports_clinic/controllers/player_management_portal.py +++ b/bemade_sports_clinic/controllers/player_management_portal.py @@ -596,13 +596,13 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): team_id_list = [int(tid) for tid in request.httprequest.form.getlist('team_ids')] except Exception: team_id_list = [] - if team_id_list: - # Defense-in-depth: restrict to teams where current user is staff - allowed_team_ids = request.env['sports.team.staff'].search([ - ('partner_id', '=', request.env.user.partner_id.id) - ]).mapped('team_id').ids - filtered_team_ids = [tid for tid in team_id_list if tid in allowed_team_ids] - vals['team_ids'] = [(6, 0, list(set(filtered_team_ids)))] + # Defense-in-depth: restrict to teams where current user is staff + allowed_team_ids = request.env['sports.team.staff'].search([ + ('partner_id', '=', request.env.user.partner_id.id) + ]).mapped('team_id').ids + filtered_team_ids = [tid for tid in team_id_list if tid in allowed_team_ids] + # Always set the M2M command, even if empty, to allow clearing all teams + vals['team_ids'] = [(6, 0, list(set(filtered_team_ids)))] if post.get('date_of_birth'): vals.update({ 'date_of_birth': post.get('date_of_birth'), diff --git a/bemade_sports_clinic/controllers/team_staff_portal.py b/bemade_sports_clinic/controllers/team_staff_portal.py index da62581..78a8ecc 100644 --- a/bemade_sports_clinic/controllers/team_staff_portal.py +++ b/bemade_sports_clinic/controllers/team_staff_portal.py @@ -17,6 +17,10 @@ class TeamStaffPortal(CustomerPortal): activities_domain) rtn['events_count'] = http.request.env['sports.event'].search_count( events_domain) + # Timesheets count (therapists only) + user = http.request.env.user + if user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system'): + rtn['timesheets_count'] = http.request.env['sports.event.timesheet'].search_count([('user_id', '=', user.id)]) return rtn @classmethod diff --git a/bemade_sports_clinic/controllers/timesheets_portal.py b/bemade_sports_clinic/controllers/timesheets_portal.py new file mode 100644 index 0000000..c0c7373 --- /dev/null +++ b/bemade_sports_clinic/controllers/timesheets_portal.py @@ -0,0 +1,220 @@ +from odoo.addons.portal.controllers.portal import CustomerPortal, pager +from odoo import http, _, fields +from odoo.exceptions import AccessError, UserError +from datetime import datetime +import pytz +import logging + +_logger = logging.getLogger(__name__) + + +class TimesheetsPortal(CustomerPortal): + def _parse_portal_datetime(self, val): + """Parse a datetime-local input (YYYY-MM-DDTHH:MM[:SS]) from the portal + as user-local time and convert to UTC string suitable for fields.Datetime. + Returns False if empty. + """ + if not val: + return False + dt = None + try: + if 'T' in val: + try: + dt = datetime.strptime(val, '%Y-%m-%dT%H:%M') + except ValueError: + dt = datetime.strptime(val, '%Y-%m-%dT%H:%M:%S') + else: + try: + dt = datetime.strptime(val, '%Y-%m-%d %H:%M') + except ValueError: + dt = datetime.strptime(val, '%Y-%m-%d %H:%M:%S') + except ValueError: + # Fallback: let ORM try + return val + + tz_name = (http.request.context.get('tz') if http.request and http.request.context else None) or \ + (http.request.env.user.tz if http.request else None) or 'UTC' + try: + user_tz = pytz.timezone(tz_name) + except Exception: + user_tz = pytz.UTC + local_dt = user_tz.localize(dt) + utc_dt = local_dt.astimezone(pytz.UTC) + return fields.Datetime.to_string(utc_dt) + + def _prepare_home_portal_values(self, counters): + vals = super()._prepare_home_portal_values(counters) + if 'event_timesheets_count' in counters: + user = http.request.env.user + if user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system'): + # Count timesheets owned by the current user OR by an internal user sharing the same partner + count_domain = ['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)] + count = http.request.env['sports.event.timesheet'].search_count(count_domain) + vals['event_timesheets_count'] = count + _logger.debug( + "[TimesheetsPortal] Home counter (event_timesheets_count) user=%s(partner=%s) domain=%s -> count=%s", + user.id, user.partner_id.id, count_domain, count, + ) + return vals + + def _prepare_timesheets_domain(self, user_only=True): + user = http.request.env.user + if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')): + # No access for non-therapists in portal + return [('id', '=', 0)] + domain = [] + if user_only: + # Show records owned by the exact user or by an internal user sharing the same partner + domain.extend(['|', ('user_id', '=', user.id), ('user_id.partner_id', '=', user.partner_id.id)]) + _logger.debug( + "[TimesheetsPortal] _prepare_timesheets_domain user=%s(partner=%s) user_only=%s -> %s", + user.id, user.partner_id.id, user_only, domain, + ) + return domain + + @http.route(['/my/sc/timesheets', '/my/sc/timesheets/page/'], type='http', auth='user', website=True) + def view_timesheets(self, page=1, date_from=None, date_to=None, team_id=None, organization_id=None, group_by=None, sortby=None, search=None, **kw): + _logger.warning("[TimesheetsPortal] ENTER /my/sc/timesheets page=%s", page) + user = http.request.env.user + if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')): + raise AccessError(_("You don't have access to timesheets.")) + + domain = self._prepare_timesheets_domain(user_only=True) + + # Filters + _logger.debug( + "[TimesheetsPortal] Incoming filters user=%s(partner=%s) date_from=%s date_to=%s team_id=%s org_id=%s group_by=%s sortby=%s search=%s", + user.id, user.partner_id.id, date_from, date_to, team_id, organization_id, group_by, sortby, search, + ) + if date_from: + try: + dt = fields.Datetime.from_string(date_from) + domain.append(('coverage_start', '>=', dt)) + except Exception: + pass + if date_to: + try: + dt = fields.Datetime.from_string(date_to) + domain.append(('coverage_start', '<=', dt)) + except Exception: + pass + if team_id: + try: + domain.append(('event_id.team_id', '=', int(team_id))) + except Exception: + pass + if organization_id: + try: + domain.append(('event_id.partner_id', '=', int(organization_id))) + except Exception: + pass + if search: + domain.extend(['|', '|', + ('event_id.name', 'ilike', search), + ('event_id.team_id.name', 'ilike', search), + ('event_id.partner_id.name', 'ilike', search)]) + + # Sorting + sort_options = { + 'date': 'coverage_start asc', + 'date_desc': 'coverage_start desc', + 'team': 'event_id.team_id', + 'org': 'event_id.partner_id', + 'state': 'state', + } + order = sort_options.get(sortby, 'coverage_start asc') + + Timesheet = http.request.env['sports.event.timesheet'] + _logger.debug("[TimesheetsPortal] Final domain=%s order=%s", domain, order) + total = Timesheet.search_count(domain) + _logger.debug("[TimesheetsPortal] search_count=%s", total) + pgr = pager(url='/my/sc/timesheets', total=total, page=page, step=self._items_per_page, + url_args={'date_from': date_from, 'date_to': date_to, 'team_id': team_id, 'organization_id': organization_id, + 'group_by': group_by, 'sortby': sortby, 'search': search}) + timesheets = Timesheet.search(domain, order=order, limit=self._items_per_page, offset=pgr['offset']) + _logger.debug("[TimesheetsPortal] search result ids=%s", timesheets.ids) + + # Filters data sources + teams = http.request.env['sports.team'].search([]) + orgs = teams.mapped('parent_id').filtered(lambda p: p).sorted('name') + _logger.debug( + "[TimesheetsPortal] UI sources: teams=%s orgs=%s", + len(teams), len(orgs), + ) + + # Grouping structure + grouped = None + if group_by in ('date', 'organization', 'team'): + from collections import OrderedDict + grouped = OrderedDict() + for ts in timesheets: + if group_by == 'date': + key = (fields.Date.to_string(fields.Datetime.context_timestamp(user, ts.coverage_start).date()) if ts.coverage_start else 'No Date') + label = key + elif group_by == 'organization': + key = ts.event_id.partner_id.id or 0 + label = ts.event_id.partner_id.name or _('No Organization') + else: # team + key = ts.event_id.team_id.id or 0 + label = ts.event_id.team_id.name or _('No Team') + if key not in grouped: + grouped[key] = {'label': label, 'items': []} + grouped[key]['items'].append(ts) + grouped = list(grouped.values()) + + # Build user-local datetime strings for datetime-local inputs in the edit modal + # Format: '%Y-%m-%dT%H:%M' expected by HTML input type=datetime-local + local_dt_map = {} + for ts in timesheets: + def _fmt(dt): + if not dt: + return '' + try: + return fields.Datetime.context_timestamp(user, dt).strftime('%Y-%m-%dT%H:%M') + except Exception: + # Fallback to raw string + return fields.Datetime.to_string(dt)[:16].replace(' ', 'T') + local_dt_map[ts.id] = { + 'travel_start': _fmt(ts.travel_start), + 'coverage_start': _fmt(ts.coverage_start), + 'coverage_end': _fmt(ts.coverage_end), + 'travel_end': _fmt(ts.travel_end), + } + + values = { + 'page_name': 'timesheets', + 'timesheets': timesheets, + 'pager': pgr, + 'default_url': '/my/sc/timesheets', + 'date_from': date_from, + 'date_to': date_to, + 'team_id': int(team_id) if team_id else None, + 'organization_id': int(organization_id) if organization_id else None, + 'group_by': group_by, + 'sortby': sortby, + 'search': search, + 'teams': teams, + 'organizations': orgs, + 'grouped': grouped, + # Mapping of timesheet.id -> localized datetime strings for edit modal fields + 'timesheet_local_dt': local_dt_map, + } + return http.request.render('bemade_sports_clinic.portal_timesheets_list', values) + + @http.route(['/my/sc/timesheet//edit'], type='http', auth='user', website=True, methods=['POST'], csrf=False) + def edit_timesheet(self, ts_id, **post): + user = http.request.env.user + if not (user.has_group('bemade_sports_clinic.group_portal_treatment_professional') or user.has_group('base.group_system')): + raise AccessError(_("You don't have permission to edit timesheets.")) + ts = http.request.env['sports.event.timesheet'].browse(ts_id) + if not ts.exists() or ts.user_id.id != user.id: + raise AccessError(_("You can only edit your own timesheets.")) + if ts.state == 'invoiced': + raise UserError(_('This timesheet is invoiced and cannot be edited.')) + vals = {} + for k in ['travel_start', 'coverage_start', 'coverage_end', 'travel_end']: + if post.get(k): + vals[k] = self._parse_portal_datetime(post.get(k)) + if vals: + ts.write(vals) + return http.request.redirect('/my/sc/timesheets?updated=1') diff --git a/bemade_sports_clinic/models/__init__.py b/bemade_sports_clinic/models/__init__.py index 5365dad..4af066c 100644 --- a/bemade_sports_clinic/models/__init__.py +++ b/bemade_sports_clinic/models/__init__.py @@ -13,3 +13,4 @@ from . import project_project from . import sports_event from . import base_partner_merge from . import team_role_mass_assign_wizard +from . import sports_event_timesheet diff --git a/bemade_sports_clinic/models/sports_event.py b/bemade_sports_clinic/models/sports_event.py index 6d590dc..bf65456 100644 --- a/bemade_sports_clinic/models/sports_event.py +++ b/bemade_sports_clinic/models/sports_event.py @@ -6,7 +6,7 @@ class SportsEvent(models.Model): _name = 'sports.event' _description = 'Sports Event' _inherit = ['mail.thread', 'mail.activity.mixin'] - _order = 'date_start desc, name' + _order = 'date_start asc, name' _rec_name = 'name' # Portal access group definition - only authorized portal users @@ -90,6 +90,7 @@ class SportsEvent(models.Model): ('confirmed', 'Confirmed'), ('in_progress', 'In Progress'), ('completed', 'Completed'), + ('invoiced', 'Invoiced'), ('cancelled', 'Cancelled') ], string='Status', default='confirmed', tracking=True, groups=_portal_groups) @@ -116,6 +117,20 @@ class SportsEvent(models.Model): groups=_portal_groups, help='Treatment professionals assigned to this event' ) + + # Timesheets: one per assigned therapist + timesheet_ids = fields.One2many( + 'sports.event.timesheet', 'event_id', + string='Timesheets', + help='Timesheets logged by assigned therapists for this event' + ) + + timesheet_count = fields.Integer( + string='Timesheet Count', + compute='_compute_timesheet_count', + store=False, + help='Number of timesheets recorded for this event' + ) # ======================================== # TASK INTEGRATION (Internal Management) @@ -176,6 +191,15 @@ class SportsEvent(models.Model): compute='_compute_is_upcoming', help='Whether the event is in the future' ) + + # Helper field used in views to filter staff pickers to treatment professionals only + treatment_professional_user_ids = fields.Many2many( + 'res.users', + string='Treatment Professional Users', + compute='_compute_treatment_professional_user_ids', + store=False, + help='All users who are treatment professionals (internal and portal). Used to filter staff selection.' + ) # ======================================== # COMPUTED METHODS @@ -241,6 +265,29 @@ class SportsEvent(models.Model): event.is_upcoming = event.date_start > now else: event.is_upcoming = False + + def _compute_treatment_professional_user_ids(self): + """Compute the list of users who are treatment professionals. + + Includes both internal treatment professionals and portal treatment professionals. + """ + # Resolve groups safely via env.ref + tp_internal = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional', raise_if_not_found=False) + tp_portal = self.env.ref('bemade_sports_clinic.group_portal_treatment_professional', raise_if_not_found=False) + group_ids = [g.id for g in (tp_internal, tp_portal) if g] + + users = self.env['res.users'] + if group_ids: + users = users.search([('active', '=', True), ('groups_id', 'in', group_ids)]) + else: + users = users.browse() + + for event in self: + event.treatment_professional_user_ids = users + + def _compute_timesheet_count(self): + for event in self: + event.timesheet_count = len(event.timesheet_ids) # ======================================== # ONCHANGE METHODS @@ -249,13 +296,13 @@ class SportsEvent(models.Model): @api.onchange('date_start') def _onchange_date_start(self): """When event start changes: - - therapist_start = 120 minutes prior + - therapist_start = same as date_start - date_end = 2 hours after - therapist_end = date_end """ if self.date_start: from datetime import timedelta - self.therapist_start = self.date_start - timedelta(minutes=120) + self.therapist_start = self.date_start self.date_end = self.date_start + timedelta(hours=2) self.therapist_end = self.date_end @@ -419,3 +466,72 @@ class SportsEvent(models.Model): task_vals['date_end'] = self.therapist_end self.task_id.write(task_vals) + + # ======================================== + # INTERNAL WORKFLOW ACTIONS + # ======================================== + def action_mark_in_progress(self): + """Mark event as In Progress (internal users only)""" + internal_user = self.env.user.has_group('base.group_user') + if not internal_user: + # Safety guard: internal-only + raise ValidationError("Only internal users can change event workflow state.") + for event in self: + if event.state in ('draft', 'confirmed'): + event.write({'state': 'in_progress'}) + try: + event.message_post(body="Event marked In Progress") + except Exception: + pass + return True + + def action_mark_completed(self): + """Mark event as Completed (internal users only)""" + internal_user = self.env.user.has_group('base.group_user') + if not internal_user: + raise ValidationError("Only internal users can change event workflow state.") + for event in self: + if event.state in ('in_progress', 'confirmed', 'draft'): + # Non-blocking warning if not all assigned therapists have timesheets + missing_users = event._get_missing_timesheet_user_ids() + if missing_users: + # Notify but do not block + try: + names = ', '.join(missing_users.mapped('name')) + event.message_post(body=f"Warning: Completing event without timesheets for: {names}") + except Exception: + pass + event.write({'state': 'completed'}) + try: + event.message_post(body="Event marked Completed") + except Exception: + pass + return True + + # ======================================== + # HELPERS + # ======================================== + def _get_missing_timesheet_user_ids(self): + """Return res.users records for assigned staff who do not have a timesheet yet""" + self.ensure_one() + assigned = self.assigned_staff_ids + if not assigned: + return self.env['res.users'] + have_ts_users = self.timesheet_ids.mapped('user_id') + missing = assigned - have_ts_users + return missing + + def action_mark_invoiced(self): + """Mark event as Invoiced (internal users only). Typically done after billing.""" + internal_user = self.env.user.has_group('base.group_user') + if not internal_user: + raise ValidationError("Only internal users can change event workflow state.") + for event in self: + # Allow invoiced from completed or cancelled (if billed anyway), and idempotent + if event.state in ('completed', 'cancelled', 'invoiced'): + event.write({'state': 'invoiced'}) + try: + event.message_post(body="Event marked Invoiced") + except Exception: + pass + return True diff --git a/bemade_sports_clinic/models/sports_event_timesheet.py b/bemade_sports_clinic/models/sports_event_timesheet.py new file mode 100644 index 0000000..e6fff39 --- /dev/null +++ b/bemade_sports_clinic/models/sports_event_timesheet.py @@ -0,0 +1,186 @@ +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.') diff --git a/bemade_sports_clinic/security/ir.model.access.csv b/bemade_sports_clinic/security/ir.model.access.csv index 239ef30..b31fda2 100644 --- a/bemade_sports_clinic/security/ir.model.access.csv +++ b/bemade_sports_clinic/security/ir.model.access.csv @@ -70,3 +70,6 @@ access_team_role_mass_assign_wizard_admin,Admin Access for Team Role Mass Assign access_team_role_mass_assign_wizard_admin2,Clinic Admin Access for Team Role Mass Assign Wizard,model_team_role_mass_assign_wizard,bemade_sports_clinic.group_sports_clinic_admin,1,1,1,1 access_team_role_mass_assign_line_admin,Admin Access for Team Role Mass Assign Line,model_team_role_mass_assign_line,base.group_system,1,1,1,1 access_team_role_mass_assign_line_admin2,Clinic Admin Access for Team Role Mass Assign Line,model_team_role_mass_assign_line,bemade_sports_clinic.group_sports_clinic_admin,1,1,1,1 + +access_event_timesheet_user,User Access for Event Timesheets,model_sports_event_timesheet,base.group_user,1,1,1,1 +access_event_timesheet_portal_tp,Portal TP Access for Event Timesheets,model_sports_event_timesheet,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 diff --git a/bemade_sports_clinic/security/sports_event_timesheet_rules.xml b/bemade_sports_clinic/security/sports_event_timesheet_rules.xml new file mode 100644 index 0000000..5a1b2b6 --- /dev/null +++ b/bemade_sports_clinic/security/sports_event_timesheet_rules.xml @@ -0,0 +1,52 @@ + + + + + + + Event Timesheet: Internal Users + + [(1, '=', 1)] + + + + + + + + + + Event Timesheet: Portal TP Read All + + [(1, '=', 1)] + + + + + + + + + + Event Timesheet: Portal TP Write Own + + [('user_id', '=', user.id)] + + + + + + + + + + Event Timesheet: Portal TP Create Own + + [('user_id', '=', user.id)] + + + + + + + diff --git a/bemade_sports_clinic/views/events_portal_templates.xml b/bemade_sports_clinic/views/events_portal_templates.xml index 67497a3..277a303 100644 --- a/bemade_sports_clinic/views/events_portal_templates.xml +++ b/bemade_sports_clinic/views/events_portal_templates.xml @@ -344,7 +344,7 @@ - (sched ) + diff --git a/bemade_sports_clinic/views/portal_event_detail_template.xml b/bemade_sports_clinic/views/portal_event_detail_template.xml index ca48859..92f3baf 100644 --- a/bemade_sports_clinic/views/portal_event_detail_template.xml +++ b/bemade_sports_clinic/views/portal_event_detail_template.xml @@ -24,6 +24,44 @@ + + + + + @@ -39,6 +77,16 @@ Edit Event + + + +
+ +
@@ -52,18 +100,62 @@ + + + + + + + + + + + +
-
- Event Details -
+
-
-
+
+
+
Type:
@@ -148,6 +240,43 @@
Description:
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TherapistTravel StartCoverage StartCoverage EndTravel EndCoverage (h)Travel (h)
+
+
+ +

No timesheets yet.

+
+
diff --git a/bemade_sports_clinic/views/portal_event_edit_template.xml b/bemade_sports_clinic/views/portal_event_edit_template.xml index 490cac3..d699da5 100644 --- a/bemade_sports_clinic/views/portal_event_edit_template.xml +++ b/bemade_sports_clinic/views/portal_event_edit_template.xml @@ -91,16 +91,21 @@
- + +
- - + +
+ Draft + Confirmed + In Progress + Completed + Invoiced + Cancelled +
diff --git a/bemade_sports_clinic/views/portal_timesheets_templates.xml b/bemade_sports_clinic/views/portal_timesheets_templates.xml new file mode 100644 index 0000000..5a068c4 --- /dev/null +++ b/bemade_sports_clinic/views/portal_timesheets_templates.xml @@ -0,0 +1,273 @@ + + + + + + + + + + diff --git a/bemade_sports_clinic/views/res_partner_views.xml b/bemade_sports_clinic/views/res_partner_views.xml index 02ba36c..676492c 100644 --- a/bemade_sports_clinic/views/res_partner_views.xml +++ b/bemade_sports_clinic/views/res_partner_views.xml @@ -1,5 +1,6 @@ + res.partner.view.form.sports.teams @@ -31,4 +32,6 @@ + + diff --git a/bemade_sports_clinic/views/res_users_views.xml b/bemade_sports_clinic/views/res_users_views.xml index 4e10253..9461c9e 100644 --- a/bemade_sports_clinic/views/res_users_views.xml +++ b/bemade_sports_clinic/views/res_users_views.xml @@ -1,5 +1,6 @@ + view.users.form.inherit @@ -48,10 +49,10 @@ - + \ No newline at end of file diff --git a/bemade_sports_clinic/views/sports_clinic_menus.xml b/bemade_sports_clinic/views/sports_clinic_menus.xml index 1db9ab2..1a94bb6 100644 --- a/bemade_sports_clinic/views/sports_clinic_menus.xml +++ b/bemade_sports_clinic/views/sports_clinic_menus.xml @@ -36,23 +36,49 @@ sports.patient list,kanban,form + + + + Event Timesheets + sports.event.timesheet + list,form + + - - - - + action="action_view_team"/> + + + + + + + + diff --git a/bemade_sports_clinic/views/sports_clinic_portal_views.xml b/bemade_sports_clinic/views/sports_clinic_portal_views.xml index 0714865..397e657 100644 --- a/bemade_sports_clinic/views/sports_clinic_portal_views.xml +++ b/bemade_sports_clinic/views/sports_clinic_portal_views.xml @@ -27,10 +27,29 @@ events_count + + Event Timesheets + + event_timesheets_count + +
+ + +