diff --git a/bemade_sports_clinic/__manifest__.py b/bemade_sports_clinic/__manifest__.py index 938b0db..52a5cd9 100644 --- a/bemade_sports_clinic/__manifest__.py +++ b/bemade_sports_clinic/__manifest__.py @@ -101,7 +101,17 @@ This module provides a complete sports medicine clinic management solution with "application": True, "assets": { "web.assets_frontend": [ + # Ensure legacy jQuery helpers exist where some website widgets expect them + "bemade_sports_clinic/static/src/js/jquery_scrolling_polyfill.js", + # Defensive patch for website TOC snippet to avoid null textContent errors + "bemade_sports_clinic/static/src/js/website_toc_safety_patch.js", "bemade_sports_clinic/static/src/scss/portal_badges.scss", ], + # Also load in lazy bundle since many website widgets initialize lazily + "web.assets_frontend_lazy": [ + "bemade_sports_clinic/static/src/js/jquery_scrolling_polyfill.js", + # Ensure patch is also present in lazy assets where snippet may initialize + "bemade_sports_clinic/static/src/js/website_toc_safety_patch.js", + ], }, } diff --git a/bemade_sports_clinic/controllers/patient_injury_portal.py b/bemade_sports_clinic/controllers/patient_injury_portal.py index 6a49d92..daab356 100644 --- a/bemade_sports_clinic/controllers/patient_injury_portal.py +++ b/bemade_sports_clinic/controllers/patient_injury_portal.py @@ -126,7 +126,6 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): 'patient_id': patient.id, 'diagnosis': post.get('diagnosis', ''), 'external_notes': post.get('external_notes', ''), - 'stage': 'active', } # Handle injury date and injury_date_na checkbox @@ -153,7 +152,16 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): vals['internal_notes'] = post.get('internal_notes') # Create the injury record - portal users now have create permission - injury = request.env['sports.patient.injury'].create(vals) + # Determine role flags to choose safe context + is_internal_user = request.env.user.has_group('base.group_user') + is_tp_internal = request.env.user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_tp_portal = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + suppress_notifications = not (is_internal_user or is_tp_internal or is_tp_portal) + + env_injury = request.env['sports.patient.injury'] + if suppress_notifications: + env_injury = env_injury.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True) + injury = env_injury.create(vals) # Determine role for assignment behavior # Use request.env.user.has_group() directly to avoid security violations @@ -174,7 +182,10 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): if selected_tp_ids: # Respect explicit selection only - injury.write({'treatment_professional_ids': [(6, 0, selected_tp_ids)]}) + if suppress_notifications: + injury.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).write({'treatment_professional_ids': [(6, 0, selected_tp_ids)]}) + else: + injury.write({'treatment_professional_ids': [(6, 0, selected_tp_ids)]}) else: # No explicit selection: perform team-based auto-assignment (if a single team context exists) if team_id: @@ -204,7 +215,10 @@ class PatientInjuryPortal(CustomerPortal, AccessControlMixin): _logger.warning("No valid therapists found to assign to the injury for team %s", selected_team_id) if team_tp_user_ids: - injury.write({'treatment_professional_ids': [(6, 0, list(team_tp_user_ids))]}) + if suppress_notifications: + injury.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).write({'treatment_professional_ids': [(6, 0, list(team_tp_user_ids))]}) + else: + injury.write({'treatment_professional_ids': [(6, 0, list(team_tp_user_ids))]}) else: _logger.info( "Skipping team-based therapist auto-assignment: patient %s has %s teams", diff --git a/bemade_sports_clinic/controllers/player_management_portal.py b/bemade_sports_clinic/controllers/player_management_portal.py index 5d5748b..2c3f4ed 100644 --- a/bemade_sports_clinic/controllers/player_management_portal.py +++ b/bemade_sports_clinic/controllers/player_management_portal.py @@ -22,6 +22,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): """ user = request.env.user is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') # Gather query params for search-first flow first_name = (get.get('first_name') or '').strip() @@ -73,6 +74,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): values.update({ 'page_name': 'create_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'all_teams': all_teams, 'states': states, 'countries': countries, @@ -95,6 +97,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): """Handle standalone player creation submit.""" user = request.env.user is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') first_name = (post.get('first_name') or '').strip() last_name = (post.get('last_name') or '').strip() @@ -132,6 +135,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): values.update({ 'page_name': 'create_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'all_teams': all_teams, 'states': states, 'countries': countries, @@ -330,8 +334,8 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): }) return request.render('bemade_sports_clinic.portal_create_player', values) - # Optionally create a primary emergency contact if provided (TPs only) - if is_treatment_prof: + # Optionally create a primary emergency contact if provided (TPs and coaches) + if is_treatment_prof or is_coach: ec_name = (post.get('ec_name') or '').strip() ec_type = (post.get('ec_contact_type') or '').strip() if ec_name and ec_type: @@ -361,9 +365,10 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): return_url = post.get('return_url', f'/my/player?player_id={patient_id}') - # Check if user is a treatment professional + # Check if user is a treatment professional or coach user = request.env.user is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') teams = patient.team_ids # All teams for multi-select limited to current user's staff teams @@ -373,6 +378,31 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): ]).mapped('team_id').ids) ], order='name') + # Determine team context from query or post and whether a coach can request removal + # Only consider a team context if the current user is staff on that team and the player is a member + raw_team_ctx = request.httprequest.args.get('team_id') or post.get('team_id') + team_context_id = None + if raw_team_ctx: + try: + cand_id = int(raw_team_ctx) + # Validate access via shared mixin helper + team_obj = self._check_team_access(cand_id, check_staff=True) + if team_obj and team_obj in teams: + team_context_id = cand_id + except Exception: + # Ignore invalid team context silently + team_context_id = None + + # Compute removal visibility/target for coaches + player_team_count = len(teams) + can_request_removal = bool(is_coach and ((team_context_id is not None) or (player_team_count == 1))) + removal_team_id = None + if can_request_removal: + if team_context_id is not None: + removal_team_id = team_context_id + elif player_team_count == 1: + removal_team_id = teams[0].id + # Create a dictionary with patient info for protected fields patient_info = {} @@ -426,6 +456,11 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): 'return_url': return_url, 'page_name': 'edit_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, + # Coach removal request context + 'can_request_removal': can_request_removal, + 'removal_team_id': removal_team_id, + 'team_context_id': team_context_id, } return request.render('bemade_sports_clinic.portal_edit_player', values) @@ -443,8 +478,9 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): except UserError as e: return request.render('portal.403', {'error': str(e)}) - # Check if user is a treatment professional + # Check if user is a treatment professional or coach is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') # If DOB is being changed/provided, validate format upfront to avoid unhelpful errors dob_str = (post.get('date_of_birth') or '').strip() @@ -474,6 +510,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): 'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'), 'page_name': 'edit_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'error': _('Please enter a valid Date of Birth (YYYY-MM-DD).'), 'form_data': dict(post), } @@ -504,6 +541,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): 'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'), 'page_name': 'edit_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'error': _('Date of Birth must not be in the future and not be more than 120 years ago.'), 'form_data': dict(post), } @@ -623,6 +661,7 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): 'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'), 'page_name': 'edit_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'error': str(ve) or _('Invalid combination of match and practice status.'), 'form_data': dict(post), } @@ -651,13 +690,14 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): 'return_url': post.get('return_url', f'/my/player?player_id={patient_id}'), 'page_name': 'edit_player', 'is_treatment_prof': is_treatment_prof, + 'is_coach': is_coach, 'error': str(e) or _('An unexpected error occurred.'), 'form_data': dict(post), } return request.render('bemade_sports_clinic.portal_edit_player', values) - # Update or create primary emergency contact (TPs only), regardless of patient field changes - if is_treatment_prof: + # Update or create primary emergency contact (TPs and coaches), regardless of patient field changes + if is_treatment_prof or is_coach: ec_name = (post.get('ec_name') or '').strip() ec_type = (post.get('ec_contact_type') or '').strip() ec_mobile = (post.get('ec_mobile') or '').strip() @@ -716,12 +756,13 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): return_url = post.get('return_url', f'/my/player?player_id={patient_id}') - # Check if user is a treatment professional + # Check if user is a treatment professional or coach user = request.env.user is_treatment_prof = user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') - # Regular coaches shouldn't be able to add emergency contacts - if not is_treatment_prof: + # Only TPs and coaches can add emergency contacts + if not (is_treatment_prof or is_coach): return request.redirect(return_url) values = { @@ -746,11 +787,12 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): except UserError as e: return request.render('portal.403', {'error': str(e)}) - # Check if user is a treatment professional + # Check if user is a treatment professional or coach is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') - # Regular coaches shouldn't be able to add emergency contacts - if not is_treatment_prof: + # Only TPs and coaches can add emergency contacts + if not (is_treatment_prof or is_coach): return request.redirect(f'/my/player?player_id={patient_id}') # Required fields @@ -798,11 +840,12 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): return_url = post.get('return_url', f'/my/player?player_id={patient.id}') - # Check if user is a treatment professional + # Check if user is a treatment professional or coach is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') - # Regular coaches shouldn't be able to edit emergency contacts - if not is_treatment_prof: + # Only TPs and coaches can edit emergency contacts + if not (is_treatment_prof or is_coach): return request.redirect(return_url) values = { @@ -835,9 +878,10 @@ class PlayerManagementPortal(CustomerPortal, AccessControlMixin): # Check if user is a treatment professional is_treatment_prof = request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional') + is_coach = request.env.user.has_group('bemade_sports_clinic.group_portal_team_coach') - # Regular coaches shouldn't be able to edit emergency contacts - if not is_treatment_prof: + # Only TPs and coaches can edit emergency contacts + if not (is_treatment_prof or is_coach): return request.redirect(f'/my/player?player_id={patient.id}') # Required fields diff --git a/bemade_sports_clinic/controllers/team_management_portal.py b/bemade_sports_clinic/controllers/team_management_portal.py index 18cd3f3..71dc9fe 100644 --- a/bemade_sports_clinic/controllers/team_management_portal.py +++ b/bemade_sports_clinic/controllers/team_management_portal.py @@ -955,6 +955,14 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin): if not first_name or not last_name: raise UserError(_('First name and last name are required')) + # Require DOB and validate format for better triage by therapists + if not dob: + raise UserError(_('Date of birth is required')) + try: + # Validate date format (YYYY-MM-DD); will raise if invalid + fields.Date.to_date(dob) + except Exception: + raise UserError(_('Please enter a valid Date of Birth (YYYY-MM-DD).')) # Find head therapist (reuse model helper on team if available) # Fallback to admin if none @@ -964,8 +972,14 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin): if not head_user: head_user = request.env.ref('base.user_admin', raise_if_not_found=False) + # In Odoo 18, mail.activity requires res_model_id (m2o), not res_model (char) + # Portal users typically cannot read ir.model; use sudo safely. + model_rec = request.env['ir.model'].sudo().search([('model', '=', 'sports.team')], limit=1) + if not model_rec: + raise UserError(_('Internal error: target model not found. Please contact an administrator.')) + model_id = model_rec.id activity_vals = { - 'res_model': 'sports.team', + 'res_model_id': model_id, 'res_id': team.id, 'summary': _('Coach requests player addition'), 'note': _('Requested player: %s %s%s\nReason: %s') % ( @@ -978,6 +992,11 @@ class TeamManagementPortal(CustomerPortal, AccessControlMixin): if head_user: activity_vals['user_id'] = head_user.id + # Ensure activity is created as a To Do + todo_type = request.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + if todo_type: + activity_vals['activity_type_id'] = todo_type.id + request.env['mail.activity'].sudo().create(activity_vals) request.session['notification'] = { diff --git a/bemade_sports_clinic/controllers/team_staff_portal.py b/bemade_sports_clinic/controllers/team_staff_portal.py index e591827..da62581 100644 --- a/bemade_sports_clinic/controllers/team_staff_portal.py +++ b/bemade_sports_clinic/controllers/team_staff_portal.py @@ -286,6 +286,36 @@ class TeamStaffPortal(CustomerPortal): patient_info['allergies'] = player.allergies patient_info['team_info_notes'] = player.team_info_notes + # Compute removal request visibility for coaches on the player detail view + # Conditions: + # - user is a coach, and + # - a valid team context is provided (user is staff on that team AND player belongs to that team), OR + # - player belongs to exactly one team and user is staff on that team + is_coach = user.has_group('bemade_sports_clinic.group_portal_team_coach') + partner = user.partner_id + staff_team_ids = set(partner.team_staff_rel_ids.mapped('team_id.id')) + + player_team_ids = set(player.team_ids.ids) + player_team_count = len(player_team_ids) + + # Validate team context + valid_team_context = False + removal_team_id = None + team_context_id = None + if team: + team_context_id = team.id + if (team.id in staff_team_ids) and (team.id in player_team_ids): + valid_team_context = True + removal_team_id = team.id + + # Fallback: single team membership case + if not valid_team_context and player_team_count == 1: + sole_team_id = next(iter(player_team_ids)) if player_team_ids else None + if sole_team_id and sole_team_id in staff_team_ids: + removal_team_id = sole_team_id + + can_request_removal = bool(is_coach and removal_team_id) + return http.request.render( template='bemade_sports_clinic.portal_my_player_injuries', qcontext={ @@ -297,5 +327,9 @@ class TeamStaffPortal(CustomerPortal): 'page_name': 'my_player', 'is_treatment_prof': is_treatment_prof, 'patient_info': patient_info, + # Removal request context for coaches + 'can_request_removal': can_request_removal, + 'removal_team_id': removal_team_id, + 'team_context_id': team_context_id, } ) diff --git a/bemade_sports_clinic/data/cron_actions.xml b/bemade_sports_clinic/data/cron_actions.xml index b103850..e1cef14 100644 --- a/bemade_sports_clinic/data/cron_actions.xml +++ b/bemade_sports_clinic/data/cron_actions.xml @@ -29,4 +29,18 @@ 100 --> + + + + Create Injury Verification Activities + + code + model._cron_create_injury_verification_tasks() + 5 + minutes + 2025-06-27 00:05:00 + + + 80 + diff --git a/bemade_sports_clinic/models/patient.py b/bemade_sports_clinic/models/patient.py index b24340a..6a07920 100644 --- a/bemade_sports_clinic/models/patient.py +++ b/bemade_sports_clinic/models/patient.py @@ -621,18 +621,18 @@ class Patient(models.Model): if existing_activity: continue - # Find head therapist or fallback to any therapist - head_therapist = player.team_ids[0].staff_ids.filtered( - lambda s: s.role == 'therapist' and s.is_head_therapist - ) - - if not head_therapist and len(player.team_ids[0].staff_ids) > 0: - # Fallback to any therapist - head_therapist = player.team_ids[0].staff_ids.filtered( - lambda s: s.role == 'therapist' - ) - - user_id = head_therapist.user_ids[0].id if head_therapist and head_therapist.user_ids else SUPERUSER_ID + # Find head therapist (role == 'head_therapist') or fallback to any therapist + team = player.team_ids[0] + staff = team.staff_ids + head_therapist = staff.filtered(lambda s: s.role == 'head_therapist' and s.user_ids) + if not head_therapist: + # Fallback to any therapist with a linked user + head_therapist = staff.filtered(lambda s: s.role == 'therapist' and s.user_ids) + + user_id = SUPERUSER_ID + if head_therapist: + # pick first linked user id + user_id = head_therapist.user_ids[0].id # Create the activity self.env['mail.activity'].create({ diff --git a/bemade_sports_clinic/models/patient_injury.py b/bemade_sports_clinic/models/patient_injury.py index 527b894..dac0aab 100644 --- a/bemade_sports_clinic/models/patient_injury.py +++ b/bemade_sports_clinic/models/patient_injury.py @@ -197,6 +197,16 @@ class PatientInjury(models.Model): self.write({'stage': 'active'}) message = _("Injury verified by %s") % self.env.user.name self.message_post(body=message) + # Close any open verification activities for this injury + model_rec = self.env['ir.model']._get('sports.patient.injury') + verif_acts = self.env['mail.activity'].sudo().search([ + ('res_model_id', '=', model_rec.id), + ('res_id', '=', self.id), + ('summary', '=', 'Verify injury'), + ('active', '=', True), + ]) + if verif_acts: + verif_acts.action_done() return True def action_resolve_injury(self): @@ -238,10 +248,17 @@ class PatientInjury(models.Model): for rec in self: old_treatment_prof_ids[rec.id] = rec.treatment_professional_ids.ids + # Detect suppression context (portal coach flows) + suppress_followers = bool( + self.env.context.get('mail_create_nosubscribe') + or self.env.context.get('mail_notrack') + or self.env.context.get('suppress_portal_mail') + ) + res = super().write(vals) - # If treatment professionals changed, update subscriptions - if 'treatment_professional_ids' in vals: + # If treatment professionals changed, update subscriptions (unless suppressed) + if not suppress_followers and 'treatment_professional_ids' in vals: for rec in self: # Only run subscription manager if treatment professionals actually changed if rec.id in old_treatment_prof_ids and set(old_treatment_prof_ids[rec.id]) != set(rec.treatment_professional_ids.ids): @@ -263,8 +280,8 @@ class PatientInjury(models.Model): if msg: rec.message_post(body=msg) - # Also update subscriptions if internal_notes changes - if 'internal_notes' in vals: + # Also update subscriptions if internal_notes changes (unless suppressed) + if not suppress_followers and 'internal_notes' in vals: for rec in self: rec._manage_treatment_professional_subscriptions() @@ -273,6 +290,13 @@ class PatientInjury(models.Model): def _manage_treatment_professional_subscriptions(self): """Subscribe treatment professionals to both regular and internal note updates while ensuring non-treatment professionals only subscribe to external updates.""" + # Skip entirely when portal flows suppress mail operations to avoid mail.followers access + if ( + self.env.context.get('mail_create_nosubscribe') + or self.env.context.get('mail_notrack') + or self.env.context.get('suppress_portal_mail') + ): + return self.ensure_one() # Get the message subtypes @@ -371,27 +395,105 @@ class PatientInjury(models.Model): ) return res + @api.model + def _cron_create_injury_verification_tasks(self): + """Create verification activities for unverified injuries. + Assign to head therapist(s) of the injury's team, falling back to therapists. + Runs with sudo to avoid portal ACL constraints from coach-created injuries. + Deduplicates by checking for existing open 'Verify injury' activities on the same injury/user. + """ + Activity = self.env['mail.activity'].sudo() + Injury = self.sudo() + # Find all unverified injuries + injuries = Injury.search([('stage', '=', 'unverified')]) + if not injuries: + return True + + # Use generic To Do activity type + todo_type = self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False) + + for injury in injuries: + # Determine target team + team = injury.team_id + if not team and injury.patient_id.team_ids: + team = injury.patient_id.team_ids[:1] + if not team: + continue + + # Find staff for this team prioritized by head_therapist then therapist + staff = self.env['sports.team.staff'].sudo().search([ + ('team_id', '=', team.id), + ('role', 'in', ['head_therapist', 'therapist']) + ]) + if not staff: + continue + + # Prefer head therapists + # Resolve model id once + model_rec = self.env['ir.model']._get('sports.patient.injury') + existing = Activity.search([ + ('res_model_id', '=', model_rec.id), + ('res_id', '=', injury.id), + ('summary', '=', 'Verify injury'), + ('active', '=', True), + ], limit=1) + if existing: + continue + + # Choose a single assignee: prefer first head therapist user, else first therapist user + head_users = staff.filtered(lambda s: s.role == 'head_therapist').mapped('user_ids')[:1] + therapist_users = staff.filtered(lambda s: s.role == 'therapist').mapped('user_ids')[:1] + assignee = head_users or therapist_users + if not assignee: + continue + + vals = { + 'res_model_id': model_rec.id, + 'res_id': injury.id, + 'user_id': assignee.id, + 'activity_type_id': todo_type.id if todo_type else False, + 'summary': 'Verify injury', + 'note': _("Please verify this injury and set the appropriate status."), + 'date_deadline': fields.Date.context_today(self), + 'automated': True, + } + Activity.create(vals) + return True + @api.model_create_multi def create(self, vals_list): - res = super().create(vals_list) + # Determine context before creating to avoid mail.followers writes when portal/coach creates + is_treatment_professional = self.env.user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') + is_admin = self.env.user.has_group('base.group_system') + is_internal_user = self.env.user.has_group('base.group_user') + suppress_notifications = not (is_treatment_professional or is_admin or is_internal_user) + + if suppress_notifications: + # Disable auto-track, auto-log and auto-subscribe during create + res = super(PatientInjury, self.with_context( + mail_notrack=True, + mail_create_nolog=True, + mail_create_nosubscribe=True + )).create(vals_list) + else: + res = super().create(vals_list) for record in res: - # Check if the current user is a treatment professional or admin - is_treatment_professional = self.env.user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional') - is_admin = self.env.user.has_group('base.group_system') - + # Creator role checks (re-use computed flags) + # is_treatment_professional, is_admin, is_internal_user, suppress_notifications defined above + + # Set initial stage without chatter/autosubscribe for portal/coach creators if is_treatment_professional or is_admin: - # If created by a treatment professional or admin, set to active - record.write({'stage': 'active'}) + record.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).write({'stage': 'active'}) else: - # Otherwise, set to unverified - record.write({'stage': 'unverified'}) + record.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).write({'stage': 'unverified'}) + # Automatically assign therapist when creating an injury current_user = self.env.user # If the injury creator is a treatment professional, assign them if current_user.has_group('bemade_sports_clinic.group_sports_clinic_treatment_professional'): - record.treatment_professional_ids = [(4, current_user.id)] + record.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).treatment_professional_ids = [(4, current_user.id)] # Otherwise, if there's a team_id, find and assign team therapists elif record.team_id: # Find all team staff users @@ -401,8 +503,6 @@ class PatientInjury(models.Model): # Filter to only staff users who are treatment professionals if team_staff: - treatment_professional_group = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional') - # Get all users from staff and filter them by group # Use direct group membership check instead of has_group() to avoid security violations staff_users = team_staff.mapped('user_ids') treatment_prof_group = self.env.ref('bemade_sports_clinic.group_sports_clinic_treatment_professional') @@ -411,8 +511,12 @@ class PatientInjury(models.Model): ) if therapist_users: - record.treatment_professional_ids = [(6, 0, therapist_users.ids)] - - record.patient_id.recompute_followers() + record.with_context(mail_notrack=True, mail_create_nolog=True, mail_create_nosubscribe=True).treatment_professional_ids = [(6, 0, therapist_users.ids)] + + if not suppress_notifications: + # Only internal/therapist flows adjust followers; portal/coach would 403 on mail.followers + record._manage_treatment_professional_subscriptions() + # Some flows rely on recomputing followers on patient; keep for staff + record.patient_id.recompute_followers() return res diff --git a/bemade_sports_clinic/security/ir.model.access.csv b/bemade_sports_clinic/security/ir.model.access.csv index ebdcfe1..4bd2250 100644 --- a/bemade_sports_clinic/security/ir.model.access.csv +++ b/bemade_sports_clinic/security/ir.model.access.csv @@ -10,6 +10,7 @@ access_sports_event_portal_treatment_professional,Portal Treatment Professional access_sports_event_portal_team_coach,Portal Team Coach Access for Sports Events,model_sports_event,bemade_sports_clinic.group_portal_team_coach,1,0,0,0 access_patient_contact_user,User Access for Patient Contacts,model_sports_patient_contact,group_sports_clinic_user,1,1,1,1 access_patient_contact_portal_tp,Portal TP Access for Patient Contacts,model_sports_patient_contact,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 +access_patient_contact_portal_coach,Portal Coach Access for Patient Contacts,model_sports_patient_contact,bemade_sports_clinic.group_portal_team_coach,1,1,1,0 access_injury_treatment_pro,Treatment Professional Access for Injuries,model_sports_patient_injury,group_sports_clinic_treatment_professional,1,1,1,1 access_injury_portal,Portal Access for Injuries,model_sports_patient_injury,base.group_portal,1,0,1,0 access_injury_portal_tp,Portal Treatment Prof Access for Injuries,model_sports_patient_injury,bemade_sports_clinic.group_portal_treatment_professional,1,1,1,0 @@ -65,3 +66,4 @@ access_project_tags_portal_tp,Portal TP Access for Project Tags,project.model_pr access_project_milestone_portal_tp,Portal TP Access for Project Milestones,project.model_project_milestone,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 access_snailmail_letter_portal_tp,Portal TP Access for Snailmail Letters,snailmail.model_snailmail_letter,bemade_sports_clinic.group_portal_treatment_professional,1,0,0,0 access_snailmail_letter_portal_coach,Portal Coach Access for Snailmail Letters,snailmail.model_snailmail_letter,bemade_sports_clinic.group_portal_team_coach,1,0,0,0 + diff --git a/bemade_sports_clinic/security/mail_activity_portal_rules.xml b/bemade_sports_clinic/security/mail_activity_portal_rules.xml index 36ba9e7..b98b8c4 100644 --- a/bemade_sports_clinic/security/mail_activity_portal_rules.xml +++ b/bemade_sports_clinic/security/mail_activity_portal_rules.xml @@ -85,13 +85,9 @@ [ '|', '|', '|', - # Generic activity types (no specific model) ('res_model', '=', False), - # Activity types for patients ('res_model', '=', 'sports.patient'), - # Activity types for injuries ('res_model', '=', 'sports.patient.injury'), - # Activity types for teams ('res_model', '=', 'sports.team') ] @@ -144,19 +140,16 @@ [ '|', '|', - # Followers on patients they have access to through teams '&', '&', ('res_model', '=', 'sports.patient'), ('res_id', '!=', False), ('res_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.patient_ids.id') or [0]), - # Followers on injuries they have access to through teams '&', '&', ('res_model', '=', 'sports.patient.injury'), ('res_id', '!=', False), ('res_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.patient_ids.injury_ids.id') or [0]), - # Followers on teams they have access to '&', '&', ('res_model', '=', 'sports.team'), @@ -175,7 +168,6 @@ Portal Treatment Professional: Patient Access [ - # Patients they have access to through teams ('id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.patient_ids.id') or [0]) ] @@ -190,7 +182,6 @@ Portal Treatment Professional: Patient Injury Access [ - # Injuries on patients they have access to through teams ('patient_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.patient_ids.id') or [0]) ] diff --git a/bemade_sports_clinic/security/project_task_portal_rules.xml b/bemade_sports_clinic/security/project_task_portal_rules.xml index 9911983..4bdbf7e 100644 --- a/bemade_sports_clinic/security/project_task_portal_rules.xml +++ b/bemade_sports_clinic/security/project_task_portal_rules.xml @@ -8,16 +8,11 @@ [ '&', - # SECURITY: Only tasks from projects with portal visibility ('project_id.privacy_visibility', '=', 'portal'), '|', '|', '|', - # Tasks assigned to the user ('user_ids', 'in', [user.id]), - # Tasks where user is a follower ('message_partner_ids', 'in', [user.partner_id.id]), - # Tasks from projects where user is a follower ('project_id.message_partner_ids', 'in', [user.partner_id.id]), - # Tasks from projects where user's teams are partners ('project_id.partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) ] @@ -33,12 +28,9 @@ [ '&', - # SECURITY: Only projects with portal visibility ('privacy_visibility', '=', 'portal'), '|', - # Projects where user is explicitly a follower ('message_partner_ids', 'in', [user.partner_id.id]), - # Projects where user's teams are partners ('partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]) ] @@ -54,11 +46,8 @@ [ '|', '|', - # Stages from projects where user is a follower ('project_ids.message_partner_ids', 'in', [user.partner_id.id]), - # Stages from projects where user's teams are partners ('project_ids.partner_id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.id') or [0]), - # Global stages (no project restriction) ('project_ids', '=', False) ] diff --git a/bemade_sports_clinic/security/sports_clinic_rules.xml b/bemade_sports_clinic/security/sports_clinic_rules.xml index 16f83da..e78a415 100644 --- a/bemade_sports_clinic/security/sports_clinic_rules.xml +++ b/bemade_sports_clinic/security/sports_clinic_rules.xml @@ -56,6 +56,18 @@ [('patient_id.team_ids.staff_ids.user_ids', 'in', user.id)] + + Restrict Patient Contact Access to Team Coaches + + + + + + + + [('patient_id.team_ids.staff_ids.user_ids', 'in', user.id)] + + Allow Team Access to Administrators @@ -111,3 +123,4 @@ + diff --git a/bemade_sports_clinic/static/src/js/jquery_scrolling_polyfill.js b/bemade_sports_clinic/static/src/js/jquery_scrolling_polyfill.js new file mode 100644 index 0000000..c68a816 --- /dev/null +++ b/bemade_sports_clinic/static/src/js/jquery_scrolling_polyfill.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import { getScrollingElement as getTopScrollingEl } from "@web/core/utils/scrolling"; + +(function () { + const w = window; + const $ = w.jQuery; + if (!$) { + return; + } + // Polyfill only if missing to avoid overriding Odoo's legacy patch when present + if (!$.fn.getScrollingElement) { + /** + * Returns the top-level scrolling element of the current document as a jQuery collection. + * Usage parity: $().getScrollingElement()[0] + */ + $.fn.getScrollingElement = function () { + const doc = (this && this[0] && this[0].ownerDocument) || w.document; + return $(getTopScrollingEl(doc)); + }; + } + if (!$.fn.getScrollingTarget) { + /** + * Returns a jQuery collection to listen to scroll events for a given scrollable element. + * Accepts a DOM element or a jQuery collection. + * If the element is the document's scrollingElement, return $(window); otherwise return $(element). + * Usage parity: $().getScrollingTarget(elem)[0] + */ + $.fn.getScrollingTarget = function (el) { + const node = (el && el.jquery) ? el[0] : el; + const doc = (node && node.ownerDocument) || w.document; + const isDocScrollEl = node === doc.scrollingElement; + return isDocScrollEl ? $(w) : $(node); + }; + } +})(); diff --git a/bemade_sports_clinic/static/src/js/website_toc_safety_patch.js b/bemade_sports_clinic/static/src/js/website_toc_safety_patch.js new file mode 100644 index 0000000..a44ba49 --- /dev/null +++ b/bemade_sports_clinic/static/src/js/website_toc_safety_patch.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; + +// Safely guard against null elements in TOC snippet's _stripNavbarStyles +if (publicWidget && publicWidget.registry && publicWidget.registry.snippetTableOfContent) { + publicWidget.registry.snippetTableOfContent.include({ + _stripNavbarStyles() { + // This matches Odoo's original intent while being defensive + const root = this && this.el ? this.el : null; + if (!root) { + return; + } + const nodes = root.querySelectorAll('.s_table_of_content_navbar .table_of_content_link'); + nodes.forEach((node) => { + let el = node; + try { + const translationEl = el ? el.querySelector('span[data-oe-translation-state]') : null; + if (translationEl instanceof Element) { + el = translationEl; + } + if (el && typeof el.textContent !== 'undefined') { + const text = el.textContent || ''; + el.textContent = text; + } + } catch (_e) { + // Silently ignore to avoid breaking the page due to malformed DOM + } + }); + }, + }); +} diff --git a/bemade_sports_clinic/static/src/scss/portal_badges.scss b/bemade_sports_clinic/static/src/scss/portal_badges.scss index 2c0d89c..25d75d4 100644 --- a/bemade_sports_clinic/static/src/scss/portal_badges.scss +++ b/bemade_sports_clinic/static/src/scss/portal_badges.scss @@ -40,3 +40,51 @@ color: #E5E5E5 !important; background-image: none !important; } + +/* Injury count badge (outline, inherit text color, no background) */ +.badge-injury-count, +.badge.badge-injury-count { + /* Neutralize theme/background overrides */ + --bs-badge-bg: transparent; + --bs-badge-color: inherit; + --background-color: transparent !important; + --color: inherit !important; + background: transparent !important; + background-image: none !important; + color: inherit !important; + border: 1px solid currentColor !important; + /* Pill shape */ + border-radius: var(--bs-border-radius-pill, 10rem) !important; +} + +/* Stronger specificity to override theme where needed */ +.portal-event-card .badge.badge-injury-count, +.badge.badge.badge-injury-count { + background: transparent !important; + background-image: none !important; + color: inherit !important; + border: 1px solid currentColor !important; + border-radius: var(--bs-border-radius-pill, 10rem) !important; +} + +/* Generic dark outlined pill (no background, dark text) */ +.badge-outline-dark-pill, +.badge.badge-outline-dark-pill { + /* Set explicit dark color and transparent background */ + --bs-badge-bg: transparent; + --bs-badge-color: var(--bs-dark, #212529); + --background-color: transparent !important; + --color: var(--bs-dark, #212529) !important; + background: transparent !important; + background-image: none !important; + color: var(--bs-dark, #212529) !important; + border: 1px solid var(--bs-dark, #212529) !important; + border-radius: var(--bs-border-radius-pill, 10rem) !important; +} + +/* Stronger specificity variant */ +.badge.badge.badge-outline-dark-pill { + background: transparent !important; + color: var(--bs-dark, #212529) !important; + border: 1px solid var(--bs-dark, #212529) !important; +} diff --git a/bemade_sports_clinic/views/events_portal_templates.xml b/bemade_sports_clinic/views/events_portal_templates.xml index 94a9fb8..67497a3 100644 --- a/bemade_sports_clinic/views/events_portal_templates.xml +++ b/bemade_sports_clinic/views/events_portal_templates.xml @@ -24,8 +24,8 @@ -
-
+
+ - + diff --git a/bemade_sports_clinic/views/player_management_portal_templates.xml b/bemade_sports_clinic/views/player_management_portal_templates.xml index 0983645..d634168 100644 --- a/bemade_sports_clinic/views/player_management_portal_templates.xml +++ b/bemade_sports_clinic/views/player_management_portal_templates.xml @@ -42,15 +42,18 @@
-
-
-
- - + + +
+
+
+ + +
-
+ @@ -179,8 +182,8 @@
- - + +
Primary Emergency Contact
@@ -286,9 +289,16 @@ Cancel - +
+ + + + +
@@ -338,6 +348,32 @@ //]]> + + + +
diff --git a/bemade_sports_clinic/views/portal_event_detail_template.xml b/bemade_sports_clinic/views/portal_event_detail_template.xml index 816aa13..ca48859 100644 --- a/bemade_sports_clinic/views/portal_event_detail_template.xml +++ b/bemade_sports_clinic/views/portal_event_detail_template.xml @@ -29,18 +29,10 @@
-
-
-

-

- - - | - -

-

-
- +
+

+
+ Back to Events diff --git a/bemade_sports_clinic/views/sports_clinic_portal_views.xml b/bemade_sports_clinic/views/sports_clinic_portal_views.xml index 6a2c815..0714865 100644 --- a/bemade_sports_clinic/views/sports_clinic_portal_views.xml +++ b/bemade_sports_clinic/views/sports_clinic_portal_views.xml @@ -31,6 +31,147 @@
+ + @@ -214,8 +355,8 @@

-
-

+
+

Players @@ -225,11 +366,11 @@

-
- +
- +
@@ -302,125 +443,18 @@
- - - - Last Name - First Name - Active Injuries - Match Status - Practice Status - Estimated Return Date - Last Consultation Date - Actions - - - + +
+
- - - - - - - - - - - - 0 - - - - - - - - - - -
- - Injury - - - - - - Pending Removal - - - - - - - - -
- - - -
- - - - - - - -
- - +
+ + + +
- -
+
+