portal(events): polish headers and detail view

- Stack action buttons onto a second line in list/detail views\n- Remove redundant team/venue under event title (retained in details card)\n- Minor styling/consistency tweaks
This commit is contained in:
Denis Durepos 2025-09-01 20:21:36 -04:00
parent a47e829935
commit 06b9c6d201
19 changed files with 760 additions and 363 deletions

View file

@ -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",
],
},
}

View file

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

View file

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

View file

@ -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'] = {

View file

@ -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,
}
)

View file

@ -29,4 +29,18 @@
<field name="priority">100</field>
</record>
-->
<!-- Scheduled action to create verification activities for unverified injuries -->
<record id="ir_cron_create_injury_verification_tasks" model="ir.cron">
<field name="name">Create Injury Verification Activities</field>
<field name="model_id" ref="model_sports_patient_injury"/>
<field name="state">code</field>
<field name="code">model._cron_create_injury_verification_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="nextcall">2025-06-27 00:05:00</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">80</field>
</record>
</odoo>

View file

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

View file

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

View file

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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
10 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
11 access_patient_contact_user User Access for Patient Contacts model_sports_patient_contact group_sports_clinic_user 1 1 1 1
12 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
13 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
14 access_injury_treatment_pro Treatment Professional Access for Injuries model_sports_patient_injury group_sports_clinic_treatment_professional 1 1 1 1
15 access_injury_portal Portal Access for Injuries model_sports_patient_injury base.group_portal 1 0 1 0
16 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
66 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
67 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
68
69

View file

@ -85,13 +85,9 @@
<field name="model_id" ref="mail.model_mail_activity_type"/>
<field name="domain_force">[
'|', '|', '|',
# 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')
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
@ -144,19 +140,16 @@
<field name="model_id" ref="mail.model_mail_followers"/>
<field name="domain_force">[
'|', '|',
# Followers on patients they have access to through teams
'&amp;',
'&amp;',
('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
'&amp;',
'&amp;',
('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
'&amp;',
'&amp;',
('res_model', '=', 'sports.team'),
@ -175,7 +168,6 @@
<field name="name">Portal Treatment Professional: Patient Access</field>
<field name="model_id" ref="bemade_sports_clinic.model_sports_patient"/>
<field name="domain_force">[
# Patients they have access to through teams
('id', 'in', user.partner_id.team_staff_rel_ids.mapped('team_id.patient_ids.id') or [0])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
@ -190,7 +182,6 @@
<field name="name">Portal Treatment Professional: Patient Injury Access</field>
<field name="model_id" ref="bemade_sports_clinic.model_sports_patient_injury"/>
<field name="domain_force">[
# 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])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>

View file

@ -8,16 +8,11 @@
<field name="model_id" ref="project.model_project_task"/>
<field name="domain_force">[
'&amp;',
# 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])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
@ -33,12 +28,9 @@
<field name="model_id" ref="project.model_project_project"/>
<field name="domain_force">[
'&amp;',
# 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])
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>
@ -54,11 +46,8 @@
<field name="model_id" ref="project.model_project_task_type"/>
<field name="domain_force">[
'|', '|',
# 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)
]</field>
<field name="groups" eval="[(4, ref('bemade_sports_clinic.group_portal_treatment_professional'))]"/>

View file

@ -56,6 +56,18 @@
[('patient_id.team_ids.staff_ids.user_ids', 'in', user.id)]
</field>
</record>
<record id="restrict_patient_contact_access_to_coach_users" model="ir.rule">
<field name="name">Restrict Patient Contact Access to Team Coaches</field>
<field name="model_id" ref="model_sports_patient_contact"/>
<field name="groups" eval="[(6, 0, [ref('bemade_sports_clinic.group_portal_team_coach')])]"/>
<field name="perm_create" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_read" eval="True"/>
<field name="perm_unlink" eval="False"/>
<field name="domain_force">
[('patient_id.team_ids.staff_ids.user_ids', 'in', user.id)]
</field>
</record>
<record id="allow_team_access_to_admins" model="ir.rule">
<field name="name">Allow Team Access to Administrators</field>
<field name="model_id" ref="model_sports_team"/>
@ -111,3 +123,4 @@
</record>
</data>
</odoo>

View file

@ -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);
};
}
})();

View file

@ -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
}
});
},
});
}

View file

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

View file

@ -24,8 +24,8 @@
</div>
</div>
<!-- View Type Navigation -->
<div class="row mb-3">
<div class="col-md-6">
<div class="row mb-1">
<div class="col-12">
<ul class="nav nav-pills">
<li class="nav-item">
<a t-attf-class="nav-link #{'active' if view_type == 'all' else ''}"
@ -47,15 +47,19 @@
</li>
</ul>
</div>
<div class="col-md-6 text-end">
<a href="/my/events?view_type=all&amp;no_default_dates=1" class="btn btn-outline-secondary btn-sm me-2">
<i class="fa fa-times"/> Clear Filters
</a>
<t t-if="can_edit">
<a href="/my/event/create" class="btn btn-primary btn-sm">
<i class="fa fa-plus"/> Add Event
</div>
<div class="row mb-3">
<div class="col-12">
<div class="d-flex flex-wrap gap-2">
<a href="/my/events?view_type=all&amp;no_default_dates=1" class="btn btn-outline-secondary btn-sm">
<i class="fa fa-times"/> Clear Filters
</a>
</t>
<t t-if="can_edit">
<a href="/my/event/create" class="btn btn-primary btn-sm">
<i class="fa fa-plus"/> Add Event
</a>
</t>
</div>
</div>
</div>

View file

@ -42,15 +42,18 @@
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Date of Birth <span class="text-danger">*</span></label>
<input type="date" name="date_of_birth" class="form-control" required="required"
t-att-value="(form_data and form_data.get('date_of_birth')) or (patient_info and patient_info.get('date_of_birth')) or (patient and patient.date_of_birth) or date_of_birth or ''"/>
<!-- Date of Birth is visible/editable to treatment professionals only to respect field ACLs -->
<t t-if="is_treatment_prof">
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
<label>Date of Birth <span class="text-danger">*</span></label>
<input type="date" name="date_of_birth" class="form-control" required="required"
t-att-value="(form_data and form_data.get('date_of_birth')) or (patient_info and patient_info.get('date_of_birth')) or ''"/>
</div>
</div>
</div>
</div>
</t>
<!-- TP-only additional info placed within Player Details -->
<t t-if="is_treatment_prof">
@ -179,8 +182,8 @@
</div>
</div>
<!-- Primary Emergency Contact (TP only) -->
<t t-if="is_treatment_prof">
<!-- Primary Emergency Contact (TP and Coach) -->
<t t-if="is_treatment_prof or is_coach">
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Primary Emergency Contact</h6>
@ -286,9 +289,16 @@
<a t-att-href="return_url" class="btn bg-o-color-2 text-white">
<i class="fa fa-times"></i> Cancel
</a>
<button type="submit" class="btn bg-o-color-1 text-white">
<i class="fa fa-save"></i> Save Changes
</button>
<div>
<t t-if="can_request_removal">
<button type="button" class="btn btn-outline-danger me-2" data-bs-toggle="modal" data-bs-target="#requestRemovalModal">
<i class="fa fa-user-times"/> Request Removal
</button>
</t>
<button type="submit" class="btn bg-o-color-1 text-white">
<i class="fa fa-save"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
@ -338,6 +348,32 @@
//]]>
</script>
</t>
<!-- Coach-only: Request Removal Modal -->
<t t-if="can_request_removal">
<div class="modal fade" id="requestRemovalModal" tabindex="-1" aria-labelledby="requestRemovalModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="requestRemovalModalLabel">Request Player Removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form t-attf-action="/my/team/{{ removal_team_id }}/player/{{ patient.id }}/request_removal" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="modal-body">
<div class="mb-3">
<label for="removal_reason" class="form-label">Reason for removal <span class="text-danger">*</span></label>
<textarea id="removal_reason" name="reason" class="form-control" rows="4" placeholder="Provide a clear reason for this request" required="required"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger"><i class="fa fa-paper-plane me-1"/> Submit Request</button>
</div>
</form>
</div>
</div>
</div>
</t>
<div class="row mt-4">

View file

@ -29,18 +29,10 @@
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 t-esc="event.name"/>
<p class="text-muted mb-0">
<i class="fa fa-users"/> <span t-esc="event.team_id.name"/>
<t t-if="event.venue_id">
| <i class="fa fa-map-marker"/> <span t-esc="event.venue_id.name"/>
</t>
</p>
</div>
<div>
<a href="/my/events" class="btn btn-outline-secondary me-2">
<div>
<h2 t-esc="event.name"/>
<div class="d-flex flex-wrap gap-2">
<a href="/my/events" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left"/> Back to Events
</a>
<t t-if="can_edit">

View file

@ -31,6 +31,147 @@
</div>
</xpath>
</template>
<!-- Reusable patient card snippet used across Players and Team views -->
<template id="portal_patient_card">
<t t-set="stage" t-value="player.stage"/>
<t t-set="url"
t-value="(team and ('/my/player?player_id=' + str(player.id) + '&amp;team_id=' + str(team.id))) or ('/my/player?player_id=' + str(player.id))"/>
<div t-attf-class="card position-relative h-100 shadow-sm {{ 'border-danger' if stage == 'no_play' else 'border-warning' if stage and stage != 'healthy' else '' }}">
<!-- Header: Player name and active injuries badge -->
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<a t-attf-href="{{ url }}" class="text-decoration-none fw-bold">
<span t-field="player.last_name"/>, <span t-field="player.first_name"/>
</a>
</div>
<!-- Desktop-only: Right-aligned badges next to injury count -->
<div class="d-none d-md-flex align-items-center justify-content-end flex-wrap gap-2">
<!-- Match badge: Yes (green check) / No (red ban) -->
<t t-if="player.match_status == 'yes'">
<span class="badge bg-success text-white" title="Match: Yes">
<i class="fa fa-check me-1"/>
Match
</span>
</t>
<t t-elif="player.match_status == 'no'">
<span class="badge bg-danger text-white" title="Match: No">
<i class="fa fa-ban me-1"/>
Match
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Match: Unknown">Match</span>
</t>
<!-- Practice badge: Yes (green check) / Yes - No Contact (yellow !) / No (red ban) -->
<t t-if="player.practice_status == 'yes'">
<span class="badge bg-success text-white" title="Practice: Yes">
<i class="fa fa-check me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no_contact'">
<span class="badge bg-warning text-dark" title="Practice: Yes - No Contact">
<i class="fa fa-exclamation-triangle me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no'">
<span class="badge bg-danger text-white" title="Practice: No">
<i class="fa fa-ban me-1"/>
Practice
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Practice: Unknown">Practice</span>
</t>
<!-- Active injuries count badge -->
<t t-set="inj_count" t-value="player.active_injury_count or 0"/>
<span class="badge badge-injury-count">
<i class="fa fa-chain-broken me-1"/>
<t t-esc="inj_count"/>
</span>
</div>
</div>
<!-- Body: Teams (optional) and key fields -->
<div class="card-body">
<!-- Make whole card clickable -->
<a t-attf-href="{{ url }}" class="stretched-link" aria-label="Open player details"></a>
<!-- First line: Match/Practice badges (mobile only; desktop version is in header) -->
<div class="mb-2 d-flex d-md-none flex-wrap gap-2">
<!-- Match badge: Yes (green check) / No (red x) -->
<t t-if="player.match_status == 'yes'">
<span class="badge bg-success text-white" title="Match: Yes">
<i class="fa fa-check me-1"/>
Match
</span>
</t>
<t t-elif="player.match_status == 'no'">
<span class="badge bg-danger text-white" title="Match: No">
<i class="fa fa-ban me-1"/>
Match
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Match: Unknown">Match</span>
</t>
<!-- Practice badge: Yes (green check) / Yes - No Contact (yellow !) / No (red x) -->
<t t-if="player.practice_status == 'yes'">
<span class="badge bg-success text-white" title="Practice: Yes">
<i class="fa fa-check me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no_contact'">
<span class="badge bg-warning text-dark" title="Practice: Yes - No Contact">
<i class="fa fa-exclamation-triangle me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no'">
<span class="badge bg-danger text-white" title="Practice: No">
<i class="fa fa-ban me-1"/>
Practice
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Practice: Unknown">Practice</span>
</t>
</div>
<t t-if="not is_team_view">
<div class="mb-2">
<div class="d-flex flex-wrap gap-2">
<t t-foreach="player.sudo().team_ids" t-as="t">
<a t-attf-href="/my/team?team_id={{ t.id }}" class="badge badge-outline-dark-pill text-decoration-none">
<t t-esc="t.name"/>
</a>
</t>
<span t-if="not player.team_ids" class="text-muted small">No teams</span>
</div>
</div>
</t>
<div class="row g-2">
<div class="col-6">
<div class="text-muted small d-flex align-items-center">
<i class="fa fa-stethoscope" title="Last Consultation Date"></i>
<span class="ms-1 d-none d-md-inline">Last Consultation:</span>
<span class="ms-1" t-field="player.last_consultation_date" t-options="{'widget': 'date'}"></span>
</div>
</div>
<div class="col-6">
<div class="text-muted small d-flex align-items-center">
<i class="fa fa-clock-o" title="Estimated Return"></i>
<span class="ms-1 d-none d-md-inline">Estimated Return:</span>
<span class="ms-1" t-field="player.predicted_return_date" t-options="{'widget': 'date'}"></span>
</div>
</div>
</div>
</div>
<!-- Footer removed per spec -->
</div>
</template>
@ -214,8 +355,8 @@
</div>
</t>
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">
<div class="mb-3">
<h1 class="mb-2">
<span t-field="team.name"/> Players
<t t-if="any(p.pending_removal for p in players)" class="position-relative">
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-warning">
@ -225,11 +366,11 @@
</span>
</t>
</h1>
<div>
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white me-2">
<div class="d-flex flex-wrap gap-2">
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white">
<i class="fa fa-tasks"></i> Activities
</a>
<a t-if="is_treatment_prof" t-attf-href="/my/activity/create?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white me-2">
<a t-if="is_treatment_prof" t-attf-href="/my/activity/create?model=sports.team&amp;res_id={{ team.id }}" class="btn bg-o-color-2 text-white">
<i class="fa fa-tasks"></i> Add Activity
</a>
<!-- Therapist: go to dedicated Add/Link Player page -->
@ -269,7 +410,7 @@
</div>
<div class="col-md-6">
<label class="form-label">Date of Birth</label>
<input type="date" name="date_of_birth" class="form-control"/>
<input type="date" name="date_of_birth" class="form-control" required="required"/>
</div>
<div class="col-12">
<label class="form-label">Reason</label>
@ -302,125 +443,18 @@
</div>
</div>
</t>
<t t-call="portal.portal_table">
<thead>
<tr>
<th>Last Name</th>
<th>First Name</th>
<th>Active Injuries</th>
<th>Match Status</th>
<th>Practice Status</th>
<th>Estimated Return Date</th>
<th>Last Consultation Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Cards grid replacing table -->
<div class="container-fluid">
<div class="row g-3 justify-content-center">
<t t-foreach="players" t-as="player">
<t t-set="stage" t-value="player.stage"/>
<t t-set="url"
t-value="'/my/player?player_id=' + str(player.id) + '&amp;team_id=' + str(team.id)"/>
<tr t-attf-class="{{ 'table-warning' if player.pending_removal else '' }} {{ 'text-danger' if stage == 'no_play' else 'text-warning' if stage != 'healthy' else '' }}"
t-attf-id="{{ 'pending-removal-' + str(player.id) if player.pending_removal else '' }}">
<td>
<strong><a t-attf-href="{{ url }}" class="text-decoration-none"><span t-field="player.last_name"/></a></strong>
</td>
<td>
<strong><a t-attf-href="{{ url }}" class="text-decoration-none"><span t-field="player.first_name"/></a></strong>
</td>
<td class="text-center">
<span t-if="player.active_injury_count" t-field="player.active_injury_count"/>
<span t-else="">0</span>
</td>
<td><span t-field="player.match_status"/></td>
<td><span t-field="player.practice_status"/></td>
<td>
<span t-field="player.predicted_return_date"
t-options="{'widget': 'date'}"/>
</td>
<td>
<span t-field="player.last_consultation_date"
t-options="{'widget': 'date'}"/>
</td>
<td class="text-center">
<div class="btn-group" role="group">
<a t-attf-href="/my/patient/injury/new?patient_id={{ player.id }}&amp;team_id={{ team.id }}"
class="btn bg-o-color-1 text-white btn-sm"
t-att-disabled="player.pending_removal">
<i class="fa fa-plus"/> Injury
</a>
<!-- Pending Removal Badge -->
<t t-if="player.pending_removal" class="ms-2">
<span class="badge bg-warning text-dark" title="Pending Removal">
<i class="fa fa-hourglass-half me-1"/> Pending Removal
</span>
</t>
<!-- Show remove button for treatment professionals/team staff -->
<t t-set="is_treatment_prof" t-value="user_has_group('bemade_sports_clinic.group_portal_treatment_professional') or user_has_group('base.group_system')"/>
<t t-set="is_team_staff" t-value="team.staff_ids.filtered(lambda s: user.partner_id in s.user_ids.partner_id)"/>
<!-- For treatment professionals - direct remove -->
<t t-if="is_treatment_prof">
<form t-att-action="'/my/team/' + str(team.id) + '/player/' + str(player.id) + '/remove'" method="post" style="display: inline-block;">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit"
class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this player from the team?');">
<i class="fa fa-user-times"/> Remove
</button>
</form>
</t>
<!-- For team staff (coaches) - request removal -->
<t t-elif="is_team_staff">
<button type="button"
class="btn bg-o-color-2 text-white btn-sm"
data-bs-toggle="modal"
t-att-data-bs-target="'#requestRemovalModal' + str(player.id)"
t-att-class="'btn-warning' if not player.pending_removal else 'btn-secondary'"
t-att-disabled="player.pending_removal">
<i class="fa" t-attf-class="{{ 'fa-flag' if not player.pending_removal else 'fa-hourglass' }}" />
<t t-if="not player.pending_removal">Request Removal</t>
<t t-else="">Removal Requested</t>
</button>
<!-- Request Removal Modal -->
<div t-att-id="'requestRemovalModal' + str(player.id)" class="modal fade" tabindex="-1" t-attf-aria-labelledby="requestRemovalModalLabel#{player.id}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" t-attf-id="requestRemovalModalLabel#{player.id}">Request Player Removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form t-attf-action="/my/team/{{ team.id }}/player/{{ player.id }}/request_removal" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="modal-body">
<p>Please provide a reason for removing this player from the team. This will create a task for the head therapist to review.</p>
<div class="alert alert-info">
<i class="fa fa-info-circle"/> The player will be marked as pending removal until a therapist approves the request.
</div>
<div class="mb-3">
<label class="form-label">Reason for removal:</label>
<textarea name="reason" class="form-control" rows="3" required="required" placeholder="Please explain why this player should be removed from the team"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn bg-o-color-2 text-white" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn bg-o-color-2 text-white">
<i class="fa fa-paper-plane"/> Submit Request
</button>
</div>
</form>
</div>
</div>
</div>
</t>
</div>
</td>
</tr>
<div class="col-12 col-lg-8">
<t t-set="is_team_view" t-value="True"/>
<t t-set="show_player_activities" t-value="False"/>
<t t-call="bemade_sports_clinic.portal_patient_card"/>
</div>
</t>
</tbody>
</t>
</div>
</div>
</t>
</template>
<template id="portal_my_players">
@ -514,151 +548,132 @@
</div>
</div>
</div>
<t t-call="portal.portal_table">
<thead>
<tr>
<th>Last Name</th>
<th>First Name</th>
<th>Teams</th>
<th>Active Injuries</th>
<th>Match Status</th>
<th>Practice Status</th>
<th>Estimated Return Date</th>
<th>Last Consultation Date</th>
<th>Activities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Cards grid replacing table -->
<div class="container-fluid">
<div class="row g-3 justify-content-center">
<t t-foreach="players" t-as="player">
<t t-set="stage" t-value="player.stage"/>
<t t-set="url" t-value="'/my/player?player_id=' + str(player.id)"/>
<tr t-attf-class="{{ ('text-danger' if stage == 'no_play'
else 'text-warning') if stage != 'healthy'
else '' }}">
<td>
<strong><a t-attf-href="{{ url }}" class="text-decoration-none"><span t-field="player.last_name"/></a></strong>
</td>
<td>
<strong><a t-attf-href="{{ url }}" class="text-decoration-none"><span t-field="player.first_name"/></a></strong>
</td>
<td>
<ul class="list-unstyled">
<t t-foreach="player.sudo().team_ids" t-as="team">
<li>
<a t-attf-href="/my/team?team_id={{ team.id }}"
t-field="team.name"/>
</li>
</t>
</ul>
</td>
<td class="text-center">
<span t-if="player.active_injury_count" t-field="player.active_injury_count"/>
<span t-else="">0</span>
</td>
<td><span t-field="player.match_status"/></td>
<td><span t-field="player.practice_status"/></td>
<td>
<span t-field="player.predicted_return_date"
t-options="{'widget': 'date'}"/>
</td>
<td>
<span t-field="player.last_consultation_date"
t-options="{'widget': 'date'}"/>
</td>
<td>
<t t-set="player_activity_count" t-value="player.activity_count or 0"/>
<span t-esc="player_activity_count"/>
</td>
<td>
<a t-attf-href="/my/activities?model=sports.patient&amp;res_id={{ player.id }}"
class="btn bg-o-color-1 text-white btn-sm">
<i class="fa fa-tasks"></i> Activities
</a>
</td>
</tr>
<div class="col-12 col-lg-8">
<t t-set="is_team_view" t-value="False"/>
<t t-set="show_player_activities" t-value="True"/>
<t t-call="bemade_sports_clinic.portal_patient_card"/>
</div>
</t>
</tbody>
</t>
</div>
</div>
</t>
</template>
<template id="portal_my_player_injuries">
<t t-call="portal.portal_layout">
<div class="d-flex align-items-center mb-3">
<div class="mb-2">
<h1 class="mb-0"><span t-field="player.name"/></h1>
<div class="ms-auto">
<a t-att-href="'/my/player/edit?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white me-2">
<i class="fa fa-edit"></i> Edit Player
</a>
<a t-att-href="'/my/patient/injury/new?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white me-2">
<i class="fa fa-plus"></i> <span t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Add Injury</span><span t-else="">Report Injury</span>
</a>
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.patient&amp;res_id={{ player.id }}" class="btn bg-o-color-2 text-white me-2">
<i class="fa fa-tasks"></i> Activities
</a>
<a t-if="is_treatment_prof" t-att-href="'/my/activity/create?model=sports.patient&amp;res_id=%s' % player.id" class="btn bg-o-color-2 text-white me-2">
<i class="fa fa-tasks"></i> Add Activity
</a>
<a t-if="is_treatment_prof" t-att-href="'/my/injury/notes?patient_id=%s' % player.id" class="btn bg-o-color-2 text-white">
<i class="fa fa-clipboard-list"></i> View Notes
<span class="badge badge-pill badge-primary ms-1" t-if="player.treatment_note_count">
<t t-esc="player.treatment_note_count"/>
</span>
</a>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<a t-att-href="'/my/player/edit?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white">
<i class="fa fa-edit"></i> Edit Player
</a>
<a t-att-href="'/my/patient/injury/new?patient_id=%s' % player.id" class="btn bg-o-color-1 text-white">
<i class="fa fa-plus"></i> <span t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')">Add Injury</span><span t-else="">Report Injury</span>
</a>
<!-- Coach: Request Removal (visible only if can_request_removal True) -->
<button t-if="can_request_removal" type="button" class="btn btn-outline-danger"
data-bs-toggle="modal" t-attf-data-bs-target="#requestRemovalModal{{ player.id }}">
<i class="fa fa-user-times"></i> Request Removal
</button>
<a t-if="request.env.user.has_group('bemade_sports_clinic.group_portal_treatment_professional')" t-attf-href="/my/activities?model=sports.patient&amp;res_id={{ player.id }}" class="btn bg-o-color-2 text-white">
<i class="fa fa-tasks"></i> Activities
</a>
<a t-if="is_treatment_prof" t-att-href="'/my/activity/create?model=sports.patient&amp;res_id=%s' % player.id" class="btn bg-o-color-2 text-white">
<i class="fa fa-tasks"></i> Add Activity
</a>
<a t-if="is_treatment_prof" t-att-href="'/my/injury/notes?patient_id=%s' % player.id" class="btn bg-o-color-2 text-white">
<i class="fa fa-clipboard-list"></i> View Notes
<span class="badge badge-pill badge-primary ms-1" t-if="player.treatment_note_count">
<t t-esc="player.treatment_note_count"/>
</span>
</a>
</div>
<!-- Request Removal Modal -->
<div t-if="can_request_removal and removal_team_id" t-attf-id="requestRemovalModal{{ player.id }}" class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Request Player Removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form t-attf-action="/my/team/{{ removal_team_id }}/player/{{ player.id }}/request_removal" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Reason</label>
<textarea name="reason" class="form-control" rows="3" placeholder="Explain why this player should be removed"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="fa fa-paper-plane"></i> Submit Request
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Player Status Card -->
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<h6 class="mb-1">Match</h6>
<span t-if="player.match_status == 'yes'" class="text-success font-weight-bold h5">
<i class="fa fa-check-circle"></i> Yes
</span>
<span t-elif="player.match_status == 'no'" class="text-danger font-weight-bold h5">
<i class="fa fa-times-circle"></i> No
</span>
<span t-else="" class="text-muted font-weight-bold h5">
<i class="fa fa-question-circle"></i> <span t-esc="player.match_status or 'Unknown'"/>
</span>
<div class="row g-3 align-items-center">
<!-- Left column: Match & Practice badges with text -->
<div class="col-12 col-md-6">
<div class="d-flex flex-wrap align-items-center gap-2">
<!-- Match badge -->
<t t-if="player.match_status == 'yes'">
<span class="badge bg-success text-white" title="Match: Yes">
<i class="fa fa-check me-1"/>
Match
</span>
</t>
<t t-elif="player.match_status == 'no'">
<span class="badge bg-danger text-white" title="Match: No">
<i class="fa fa-ban me-1"/>
Match
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Match: Unknown">Match</span>
</t>
<!-- Practice badge -->
<t t-if="player.practice_status == 'yes'">
<span class="badge bg-success text-white" title="Practice: Yes">
<i class="fa fa-check me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no_contact'">
<span class="badge bg-warning text-dark" title="Practice: Yes - No Contact">
<i class="fa fa-exclamation-triangle me-1"/>
Practice
</span>
</t>
<t t-elif="player.practice_status == 'no'">
<span class="badge bg-danger text-white" title="Practice: No">
<i class="fa fa-ban me-1"/>
Practice
</span>
</t>
<t t-else="">
<span class="badge bg-secondary text-white" title="Practice: Unknown">Practice</span>
</t>
</div>
</div>
<div class="col-md-3">
<h6 class="mb-1">Practice</h6>
<span t-if="player.practice_status == 'yes'" class="text-success font-weight-bold h5">
<i class="fa fa-check-circle"></i> Yes
</span>
<span t-elif="player.practice_status == 'no_contact'" class="text-warning font-weight-bold h5">
<i class="fa fa-exclamation-triangle"></i> Yes, No Contact
</span>
<span t-elif="player.practice_status == 'no'" class="text-danger font-weight-bold h5">
<i class="fa fa-times-circle"></i> No
</span>
<span t-else="" class="text-muted font-weight-bold h5">
<i class="fa fa-question-circle"></i> <span t-esc="player.practice_status or 'Unknown'"/>
</span>
</div>
<div class="col-md-3" t-if="player.stage">
<h6 class="mb-1">Status</h6>
<span t-if="player.stage == 'healthy'" class="text-success font-weight-bold h5">
<i class="fa fa-thumbs-up"></i> Play OK
</span>
<span t-elif="player.stage == 'practice_ok'" class="text-warning font-weight-bold h5">
<i class="fa fa-running"></i> Practice OK
</span>
<span t-elif="player.stage == 'no_play'" class="text-danger font-weight-bold h5">
<i class="fa fa-ban"></i> Injured
</span>
<span t-else="" class="text-muted font-weight-bold h5">
<i class="fa fa-question-circle"></i> <span t-esc="player.stage"/>
</span>
</div>
<div class="col-md-3" t-if="(player.allergies or '').replace('\xa0','').strip()">
<h6 class="mb-1">Allergies</h6>
<div class="alert alert-warning py-2 mb-0">
<i class="fa fa-exclamation-triangle"></i>
<small t-esc="player.allergies"/>
<!-- Right column: Allergies (if present) -->
<div class="col-12 col-md-6" t-if="(player.allergies or '').replace('\xa0','').strip()">
<div class="alert alert-warning py-2 mb-0 text-start">
<strong>Allergies:</strong>
<span class="ms-1" t-esc="player.allergies"/>
</div>
</div>
</div>
@ -920,7 +935,7 @@
<td><strong>Match:</strong></td>
<td>
<span t-if="player.match_status == 'yes'" class="text-success font-weight-bold"><i class="fa fa-check-circle"></i> Yes</span>
<span t-elif="player.match_status == 'no'" class="text-danger font-weight-bold"><i class="fa fa-times-circle"></i> No</span>
<span t-elif="player.match_status == 'no'" class="text-danger font-weight-bold"><i class="fa fa-ban"></i> No</span>
<span t-else="" class="text-muted" t-esc="player.match_status"/>
</td>
</tr>
@ -929,7 +944,7 @@
<td>
<span t-if="player.practice_status == 'yes'" class="text-success font-weight-bold"><i class="fa fa-check-circle"></i> Yes</span>
<span t-elif="player.practice_status == 'no_contact'" class="text-warning font-weight-bold"><i class="fa fa-exclamation-triangle"></i> Yes, No Contact</span>
<span t-elif="player.practice_status == 'no'" class="text-danger font-weight-bold"><i class="fa fa-times-circle"></i> No</span>
<span t-elif="player.practice_status == 'no'" class="text-danger font-weight-bold"><i class="fa fa-ban"></i> No</span>
<span t-else="" class="text-muted" t-esc="player.practice_status"/>
</td>
</tr>