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 @@
-
-